banner
innei

innei

写代码是因为爱,写到世界充满爱!
github
telegram
twitter

Vercel 部署的單頁應用(SPA)動態 Meta 標籤實踐

這篇文章是實現 Follow 的 Separate main application and external pages and SEO support 這個 PR 的一部分技術細節,由於其中的技術細節較多,所以這將是系列文中的一篇。

在上一篇文章在 Vercel 部署一個 OpenGraph Image 服務中,我們介紹了如何利用 Vercel 部署一個 Node.js 伺服器。

在這篇文章中,我們會利用這個伺服器來做更多的事。其中一件事就是優化 SPA 應用的 SEO,通過 meta 標籤把我們生成的 OG Image 信息添加到 Meta 標籤中。

然後我們還需要把這兩個項目同時掛載到同一個域名上,利用 Vercel 的 Rewrite 實現。

使用 Node.js 伺服器反代 HTML#

初識 OpenGraph#

一般的社交媒體會抓取站點的 HTML 源碼,然後獲取 meta 標籤的內容展現不同的內容,往往這些抓取引擎並不具備 JS 渲染的能力,以此我們需要借助 Node.js 伺服器反代 SPA 的 HTML,然後在 HTML 中根據 URL 的路徑動態插入需要的 meta 信息。

OpenGraph 信息是社交媒體抓取數據最常見的一個類型,一個典型的 OpenGraph meta 標籤如下所示:

><meta property="og:title" content="浮光掠影的日常 | 靜かな森" /><meta
  property="og:description"
  content="又過了半個月之久。這些天發生了什麼值得記錄的呢。  一時腦熱的興趣  上個月底,國服爐石重新開服了。回想很早之前也玩過一段時間爐石,在大學的時候還把整個寢室帶入了坑,而後來我沉迷代碼又棄坑了。  這次重新開服福利還是不少的,送了去年一整年的所有卡,讓我也重新上號領了一波福利。玩了幾天之後慢慢熟悉了新"
/><meta
  property="og:image"
  content="https://innei.in/og?data=%257B%2522type%2522%253A%2522note%2522%252C%2522nid%2522%253A%2522182%2522%257D"
/><meta property="og:type" content="article" /><meta
  name="twitter:card"
  content="summary_large_image"
/><meta name="twitter:title" content="浮光掠影的日常" /><meta
  name="twitter:description"
  content="又過了半個月之久。這些天發生了什麼值得記錄的呢。  一時腦熱的興趣  上個月底,國服爐石重新開服了。回想很早之前也玩過一段時間爐石,在大學的時候還把整個寢室帶入了坑,而後來我沉迷代碼又棄坑了。  這次重新開服福利還是不少的,送了去年一整年的所有卡,讓我也重新上號領了一波福利。玩了幾天之後慢慢熟悉了新"
/><meta
  name="twitter:image"
  content="https://innei.in/og?data=%257B%2522type%2522%253A%2522note%2522%252C%2522nid%2522%253A%2522182%2522%257D"
/>

在 X 上的效果如下:

image

使用 Monorepo 分離 SPA 應用#

在一個 SPA 應用中,往往並不是所有的路由都是需要做 SEO 的,往往只有幾個需要被分享的頁面才需要。例如在 Follow 應用中,只有 /share 路由底下的需要做 SEO 優化。

例如: https://app.follow.is/share/users/innei 動態插入的 meta 標籤有:

image

現在我們來做分離項目的工作。在開始之前我們需要知道 SPA 應用是如何工作的。一個典型的由 Vite 編譯後的 SPA 應用的目錄結構如下:

dist
├── assets
│   ├── index.12345678.css
│   ├── index.12345678.js
│   └── index.12345678.js.map
├── index.html

其中 index.html 是我們的 SPA 應用的入口文件,瀏覽器在訪問 / 路由時會加載這個文件,然後根據 JS 文件動態渲染頁面。

我們只需要把這個文件使用 Node.js 進行反向代理,然後根據 URL 的路徑動態插入 meta 標籤即可。

為了分離 SPA 項目,我們需要再創建一個 SPA 應用,然後把需要做 SEO 的路由放到這個項目中。在這個過程中,我們或許會複製大量的共享組件,這或許也是需要改造的。利用 Monorepo 可以很好的解決這個問題。

例如在原先的項目中,apps/renderer 是一個完全體的 SPA 應用,現在我們創建一個新的 app 名為 server,目錄位置 apps/server,這是一個反代伺服器。創建一個用於存放前端代碼的目錄,例如 apps/server/client

image

我們復刻一個和 apps/renderer 相同的目錄結構,然後把需要的路由放到這個項目中。把通用模塊,例如組件和工具函數從 apps/renderer 中提取,抽離到 packages 目錄中。這個過程可以是漸進式的。在重構當中,為了避免這個 commit 過大和停滯時間過長造成的大量的衝突,我們可以先通過複製代碼把通用模塊從 apps/renderer 中提取到 packages 中,但是不改動原先的代碼的引用關係,在新的應用中使用 packages 的引用。例如,我們創建一個 packages/components 目錄,然後把 apps/renderer/src/components 中的部分組件提取。

創建一個包的 package.json 文件,例如:

{
  "name": "@follow/components",
  "version": "0.0.1",
  "private": true,
  "sideEffects": false,
  "exports": {
    "./tailwind": "./assets/index.css",
    "./modules/*": "./src/modules/*",
    "./common/*": "./src/common/*",
    "./icons/*": "./src/icons/*",
    "./ui/*": "./src/ui/*",
    "./providers/*": "./src/providers/*",
    "./hooks/*": "./src/hooks/*",
    "./atoms/*": "./src/atoms/*",
    "./dayjs": "./src/utils/dayjs.ts",
    "./utils/*": "./src/utils/*"
  },
  "peerDependencies": {
    "react": "^18.0.0",
    "react-dom": "^18.0.0"
  },
  "dependencies": {}
}

這個包並不需要去編譯,所以我們可以直接導出源碼,在這裡,我們使用 exports 字段來指定導出的文件,對多個目錄使用通配符導出。通用的樣式也可以導出一個通用的 CSS 文件。例如我們使用 TailwindCSS。

@import './colors.css'; /* 自定義顏色 */
@import './tailwind.css'; /* TailwindCSS */
@import './font.css'; /* 自定義字體 */

@tailwind base;
@tailwind components;
@tailwind utilities; /* Other */

apps/server 中引用這個包:

pnpm i @follow/components@workspace:*

此時在 apps/server/client 中可以直接引用這個包來使用封裝的組件:

例如:

import { Button } from '@follow/components/ui/button/index.js'

對了,如果你使用 TailwindCSS,可能還需要更改一下 content 字段。

export default resolveConfig({
  content: [
    './client/**/*.{ts,tsx}',
    './index.html',
    './node_modules/@follow/components/**/*.{ts,tsx}', // Add this line
  ],
})

提取組件和封裝通用模塊的過程是非常漫長的也是很痛苦的,在對 Follow 的改造中,我一共提取了 8 個包,移動和修改了近一萬行代碼。

image

因為我們之前都是複製的 apps/renderer 中的代碼,所以在改造完成之後,我們還需要一次性修改 renderer 中所有的代碼引用,例如 ~/components 改到 @follow/components,然後刪除所有在 renderer 的已經被遷移的組件和代碼。

Nodejs 伺服器反代 HTML#

這部分分為兩個部分,一是處理開發環境下 Vite Dev Server 和 Nodejs 伺服器的綁定,實現對 Vite Dev Server 產生的 HTML 進行修改並注入 meta 標籤。第二是處理生產環境下編譯產生的 index.html 文件。

首先來說開發環境下,我們這裡使用 Fastify 作為 Nodejs 伺服器舉例。

我們可以通過下面的方式實現和 Vite Dev Server 綁定。

let globalVite: ViteDevServer
export const registerDevViteServer = async (app: FastifyInstance) => {
  const vite = await createViteServer({
    server: { middlewareMode: true },
    appType: 'custom',

    configFile: resolve(root, 'vite.config.mts'), // vite config 的路徑
    envDir: root,
  })
  globalVite = vite

  // @ts-ignore
  app.use(vite.middlewares)
  return vite
}

在 App 初始化時期:

const app = Fastify({})

app.register(fastifyRequestContext)
await app.register(middie, {
  hook: 'onRequest',
})

if (isDev) {
  const devVite = require('./src/lib/dev-vite')
  await devVite.registerDevViteServer(app)
}

創建一個用於處理 HTML 的路由:

import { parseHTML } from 'linkedom'

app.get('*', async (req, reply) => {
  const url = req.originalUrl

  const root = resolve(__dirname, '../..')

  const vite = require('../lib/dev-vite').getViteServer()
  try {
    let template = readFileSync(
      path.resolve(root, vite.config.root, 'index.html'), // vite dev 使用 index.html 路徑
      'utf-8',
    )
    template = await vite.transformIndexHtml(url, template) // 使用 vite 去轉換
    const { document } = parseHTML(template)

    reply.type('text/html')
    reply.send(document.toString())
  } catch (e) {
    vite.ssrFixStacktrace(e)
    reply.code(500).send(e)
  }
})

這樣我們就實現了 HTML 的反代。

動態插入 Meta 標籤#

在上面的基礎上,我們對 * 路由稍作修改:

app.get('*', async (req, reply) => {
  const url = req.originalUrl

  const root = resolve(__dirname, '../..')

  const vite = require('../lib/dev-vite').getViteServer()
  try {
    let template = readFileSync(
      path.resolve(root, vite.config.root, 'index.html'),
      'utf-8',
    )
    template = await vite.transformIndexHtml(url, template)
    const { document } = parseHTML(template)
    await injectMetaToTemplate(document, req, reply) // 在這裡進行對 HTML 注入 meta 標籤
    reply.type('text/html')
    reply.send(document.toString())
  } catch (e) {
    vite.ssrFixStacktrace(e)
    reply.code(500).send(e)
  }
})

定義 Meta 的類型:

interface MetaTagdata {
  type: 'meta'
  property: string
  content: string
}

interface MetaOpenGraph {
  type: 'openGraph'
  title: string
  description?: string
  image?: string | null
}

interface MetaTitle {
  type: 'title'
  title: string
}

export type MetaTag = MetaTagdata | MetaOpenGraph | MetaTitle

實現 injectMetaToTemplate 函數:

async function injectMetaToTemplate(
  document: Document,
  req: FastifyRequest,
  res: FastifyReply,
) {
  const injectMetadata = await injectMetaHandler(req, res).catch((err) => {
    // 在 injectMetadata 中根據 URL 的路徑處理不同的 meta 標籤注入
    if (isDev) {
      throw err
    }
    return []
  })

  if (!injectMetadata) {
    return document
  }

  for (const meta of injectMetadata) {
    switch (meta.type) {
      case 'openGraph': {
        const $metaArray = buildSeoMetaTags(document, { openGraph: meta })
        for (const $meta of $metaArray) {
          document.head.append($meta)
        }
        break
      }
      case 'meta': {
        const $meta = document.createElement('meta')
        $meta.setAttribute('property', meta.property)
        $meta.setAttribute('content', xss(meta.content))
        document.head.append($meta)
        break
      }
      case 'title': {
        if (meta.title) {
          const $title = document.querySelector('title')
          if ($title) {
            $title.textContent = `${xss(meta.title)} | Follow`
          } else {
            const $head = document.querySelector('head')
            if ($head) {
              const $title = document.createElement('title')
              $title.textContent = `${xss(meta.title)} | Follow`
              $head.append($title)
            }
          }
        }
        break
      }
    }
  }

  return document
}

import xss from 'xss'

export function buildSeoMetaTags(
  document: Document,
  configs: {
    openGraph: {
      title: string
      description?: string
      image?: string | null
    }
  },
) {
  const openGraph = {
    title: xss(configs.openGraph.title),
    description: xss(configs.openGraph.description ?? ''),
    image: xss(configs.openGraph.image ?? ''),
  }

  const createMeta = (property: string, content: string) => {
    const $meta = document.createElement('meta')
    $meta.setAttribute('property', property)
    $meta.setAttribute('content', content)
    return $meta
  }

  return [
    createMeta('og:title', openGraph.title),
    createMeta('og:description', openGraph.description),
    createMeta('og:image', openGraph.image),
    createMeta('twitter:card', 'summary_large_image'),
    createMeta('twitter:title', openGraph.title),
    createMeta('twitter:description', openGraph.description),
    createMeta('twitter:image', openGraph.image),
  ]
}

實現 injectMetaHandler 函數:

import { match } from 'path-to-regexp'

export async function injectMetaHandler(
  req: FastifyRequest,
  res: FastifyReply,
): Promise<MetaTag[]> {
  const apiClient = createApiClient()
  const upstreamOrigin = req.requestContext.get('upstreamOrigin')
  const url = req.originalUrl

  for (const [pattern, handler] of Object.entries(importer)) {
    const matchFn = match(pattern, { decode: decodeURIComponent })
    const result = matchFn(url)

    if (result) {
      const parsedUrl = new URL(url, upstreamOrigin)

      return await handler({
        // 可以按需在這裡傳遞上下文
        params: result.params as Record<string, string>,
        url: parsedUrl,
        req,
        apiClient,
        origin: upstreamOrigin || '',
        setStatus(status) {
          res.status(status)
        },
        setStatusText(statusText) {
          res.raw.statusMessage = statusText
        },
        throwError(status, message) {
          throw new MetaError(status, message)
        },
      })
    }
  }

  return []
}

injectMetaHandler 中,我們 path-to-regexp 匹配類似 /share/feeds/:id 的路徑,然後從 importer map 中找到對應的處理函數,然後返回一個 MetaTag 陣列。

importer map 應該是一個根據 SPA 應用的路由自動生成的表,例如:

import i1 from '../client/pages/(main)/share/feeds/[id]/metadata'
import i2 from '../client/pages/(main)/share/lists/[id]/metadata'
import i0 from '../client/pages/(main)/share/users/[id]/metadata'

export default {
  '/share/users/:id': i0,
  '/share/feeds/:id': i1,
  '/share/lists/:id': i2,
}

在 Follow 中,SPA 的路由定義是根據文件系統樹生成的,所以我們可以根據這個特徵編寫一個 helper watcher。

import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import { dirname } from 'node:path'
import { fileURLToPath } from 'node:url'

import chokidar from 'chokidar'
import { glob } from 'glob'

const __dirname = dirname(fileURLToPath(import.meta.url))

async function generateMetaMap() {
  const files = await glob('./client/pages/(main)/**/metadata.ts', {
    cwd: path.resolve(__dirname, '..'),
  })

  const imports: string[] = []
  const routes: Record<string, string> = {}

  files.forEach((file, index) => {
    const routePath = file
      .replace('client/pages/(main)', '')
      .replace('/metadata.ts', '')
      .replaceAll(/\[([^\]]+)\]/g, ':$1')

    const importName = `i${index}`
    imports.push(`import ${importName} from "../${file.replace('.ts', '')}"`)
    routes[routePath] = importName
  })

  const content =
    '// This file is generated by `pnpm run meta`\n' +
    `${imports.join('\n')}\n
export default {
${Object.entries(routes)
  .map(([route, imp]) => `  "${route}": ${imp},`)
  .join('\n')}
}
`

  const originalContent = await fs.readFile(
    path.resolve(__dirname, '../src/meta-handler.map.ts'),
    'utf-8',
  )
  if (originalContent === content) return
  await fs.writeFile(
    path.resolve(__dirname, '../src/meta-handler.map.ts'),
    content,
    'utf-8',
  )
  console.info('Meta map generated successfully!')
}

async function watch() {
  const watchPath = path.resolve(__dirname, '..', './client/pages/(main)')
  console.info('Watching metadata files...')

  await generateMetaMap()
  const watcher = chokidar.watch(watchPath, {
    ignoreInitial: false,
  })

  watcher.on('add', () => {
    console.info('Metadata file added/changed, regenerating map...')
    generateMetaMap()
  })

  watcher.on('unlink', () => {
    console.info('Metadata file removed, regenerating map...')
    generateMetaMap()
  })

  watcher.on('change', () => {
    console.info('Metadata file changed, regenerating map...')
    generateMetaMap()
  })

  process.on('SIGINT', () => {
    watcher.close()
    process.exit(0)
  })
}

if (process.argv.includes('--watch')) {
  watch().catch(console.error)
} else {
  generateMetaMap().catch(console.error)
}

現在在路由下創建一個 metadata.ts 文件,導出一個定義 metadata 的函數。

image

import { defineMetadata } from '~/meta-handler'

export default defineMetadata(
  async ({ params, apiClient, origin, throwError }) => {
    const listId = params.id
    const list = await apiClient.lists.$get({ query: { listId } })

    const { title, description } = list.data.list
    return [
      {
        type: 'openGraph',
        title: title || '',
        description: description || '',
        image: `${origin}/og/list/${listId}`,
      },
      {
        type: 'title',
        title: title || '',
      },
    ]
  },
)

至此為止,在 dev 環境下,我們已經實現了對 Vite Dev Server 的反代,並且動態插入 meta 標籤。接下來是處理生產環境的差異。

生產環境下的 HTML 反代#

在生產環境下,我們需要反代的是編譯後的 HTML 文件。

app.get('*', async (req, reply) => {
  const template = readFileSync(
    path.resolve(root, '../dist/index.html'), // 這裡是編譯後的 HTML 文件
    'utf-8',
  )
  const { document } = parseHTML(template)
  await injectMetaToTemplate(document, req, reply)

  reply.type('text/html')
  reply.send(document.toString())
})

通過直接取代文件系統的方式在 Vercel 這類平台可能並不好使,因為編譯的產物並不會在 API 調用環境中,因此在部署到 Vercel 上會出現找不到這個文件。

我們可以通過直接打包編譯後 HTML 字符串的方式來解決這個問題。

import { mkdirSync } from 'node:fs'
import fs from 'node:fs/promises'
import path from 'node:path'

mkdirSync(path.join(__dirname, '../.generated'), { recursive: true })

async function generateIndexHtmlData() {
  const indexHtml = await fs.readFile(
    path.join(__dirname, '../dist/index.html'),
    'utf-8',
  )
  await fs.writeFile(
    path.join(__dirname, '../.generated/index.template.ts'),
    `export default ${JSON.stringify(indexHtml)}`,
  )
}

async function main() {
  await generateIndexHtmlData()
}

main()

上面的腳本中在 SPA 項目編譯後,我們把編譯後的 HTML 文件讀取,然後寫入到一個 .generated 目錄下,然後導出 HTML 字符串。

修改生產環境下的反代路由邏輯。

app.get('*', async (req, reply) => {
  const template = require('../../.generated/index.template').default 
  const { document } = parseHTML(template)
  await injectMetaToTemplate(document, req, reply)

  reply.type('text/html')
  reply.send(document.toString())
})

然後我們修改 build 流程:

cross-env NODE_ENV=production vite build && tsx scripts/prepare-vercel-build.ts && tsup

關於使用 tsup 編譯服務端,在 在 Vercel 部署一個 OpenGraph Image 服務 中有介紹。

現在有了兩個不同環境的處理邏輯,我們可以做下判斷:

export const globalRoute = isDev ? devHandler : prodHandler

部署到 Vercel#

現在我們可以部署到 Vercel 上,我們需要實現在同一個域名上掛載兩個應用。我們的主應用就是原先那個 app,然後我們需要在 Vercel 上創建一個新的應用,這個應用是用來反代 HTML 的是一個 Node.js 服務。

現在我們稱原先的 app 為 app1,新的應用為 app2。

我們需要實現的 URL 路由規則是:

/share/* -> app2
/* -> app1
https://cdn.jsdelivr.net/npm/@innei/react-cdn-components@latest/excalidraw/follow/vercel-rewrite-domain.json

在 app2 中創建或修改 vercel.json

{
  "rewrites": [
    {
      "source": "/((?!external-dist|dist-external).*)", // 把所有請求全部重寫到 Vercel 的 api route,關於 api 是什麼可以在 ![在 Vercel 部署一個 OpenGraph Image 服務](https://innei.in/posts/tech/deploy-an-opengraph-image-service-on-vercel) 中找到
      "destination": "/api"
    }
  ]
}

在 app1 中創建或修改 vercel.json

{
  "rewrites": [
    {
      "source": "/share/:path*",
      "destination": "https://<domain>/share/:path*" // 修改地址
    },
    {
      "source": "/og/:path*",
      "destination": "https://<domain>/og/:path*"
    },
    {
      "source": "/((?!assets|vendor/).*)",
      "destination": "/index.html"
    }
  ],

  "headers": []
}

經過這樣的修改之後訪問 /share 的路徑可以正確重寫到 app2,但是 app2 的資源文件全都 404 了。由於 app1 的編譯產物也在 assets 路徑下,為了兩者不衝突,我們給 app2 放一個別的路徑,例如 dist-external

修改 app2 的 vite.config.ts

import { resolve } from 'node:path'

import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'

export default () => {
  return defineConfig({
    build: {
      rollupOptions: {
        output: {
          assetFileNames: 'dist-external/[name].[hash].[ext]', 
          chunkFileNames: 'dist-external/[name].[hash].js', 
          entryFileNames: 'dist-external/[name].[hash].js', 
        },
      },
    },
    plugins: [react()],
  })
}

在 app1 中修改 vercel.json:

{
  "rewrites": [
    {
      "source": "/dist-external/:path*", 
      "destination": "https://<domain>/dist-external/:path*"
    },
    {
      "source": "/((?!assets|vendor|locales|dist-external/).*)", 
      "destination": "/index.html"
    }
  ]
}

由於篇幅過長,其他的細節請聽以後分解。

此文由 Mix Space 同步更新至 xLog
原始鏈接為 https://innei.in/posts/tech/dynamic-meta-tags-for-spa-on-vercel


載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。