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 标签如下所示：

Copy
> <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
name="twitter:card"
content="summary_large_image"
/> <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 上的效果如下：

使用 Monorepo 分离 SPA 应用

在一个 SPA 应用中，往往并不是所有的路由都是需要做 SEO 的，往往只有几个需要被分享的页面才需要。例如在 Follow 应用中，只有 /share 路由底下的需要做 SEO 优化。

例如： https://app.follow.is/share/users/innei 动态插入的 meta 标签有：

现在我们来做分离项目的工作。在开始之前我们需要知道 SPA 应用是如何工作的。一个典型的由 Vite 编译后的 SPA 应用的目录结构如下：

Copy
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。

我们复刻一个和 apps/renderer 相同的目录结构，然后把需要的路由放到这个项目中。把通用模块，例如组件和工具函数从 apps/renderer 中提取，抽离到 packages 目录中。这个过程可以是渐进式的。在重构当中，为了避免这个 commit 过大和停滞时间过长造成的大量的冲突，我们可以先通过复制代码把通用模块从 apps/renderer 中提取到 packages 中，但是不改动原先的代码的引用关系，在新的应用中使用 packages 的引用。例如，我们创建一个 packages/components 目录，然后把 apps/renderer/src/components 中的部分组件提取。

创建一个包的 package.json 文件，例如：

Copy
{
"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。

Copy
@import './colors.css'; /* 自定义颜色 */
@import './tailwind.css'; /* TailwindCSS */
@import './font.css'; /* 自定义字体 */

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

在 apps/server 中引用这个包：

Copy
pnpm i @follow/components@workspace:*

此时在 apps/server/client 中可以直接引用这个包来使用封装的组件：

例如：

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

对了，如果你使用 TailwindCSS，可能还需要更改一下 content 字段。

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

提取组件和封装通用模块的过程是非常漫长的也是很痛苦的，在对 Follow 的改造中，我一共提取了 8 个包，移动和修改了近一万行代码。

因为我们之前都是复制的 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 绑定。

Copy
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 初始化时期：

Copy
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 的路由：

Copy
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 标签

在上面的基础上，我们对 * 路由稍作修改：

Copy
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 标签 // [!code ++]
reply.type('text/html')
reply.send(document.toString())
} catch (e) {
vite.ssrFixStacktrace(e)
reply.code(500).send(e)
}
})

定义 Meta 的类型：

Copy
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 函数：

Copy
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 函数：

Copy
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 应用的路由自动生成的表，例如：

Copy
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。

Copy
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 的函数。

Copy
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 文件。

Copy
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 字符串的方式来解决这个问题。

Copy
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 字符串。

修改生产环境下的反代路由逻辑。

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

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

然后我们修改 build 流程：

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

关于使用 tsup 编译服务端，在 在 Vercel 部署一个 OpenGraph Image 服务 中有介绍。

现在有了两个不同环境的处理逻辑，我们可以做下判断：

Copy
export const globalRoute = isDev ? devHandler : prodHandler

部署到 Vercel

现在我们可以部署到 Vercel 上，我们需要实现在同一个域名上挂载两个应用。我们的主应用就是原先那个 app，然后我们需要在 Vercel 上创建一个新的应用，这个应用是用来反代 HTML 的是一个 Node.js 服务。

现在我们称原先的 app 为 app1，新的应用为 app2。

我们需要实现的 URL 路由规则是：

Copy
/share/* -> app2
/* -> app1

Copy
https://cdn.jsdelivr.net/npm/@innei/react-cdn-components@latest/excalidraw/follow/vercel-rewrite-domain.json

在 app2 中创建或修改 vercel.json：

Copy
{
"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：

Copy
{
"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：

Copy
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]', // [!code ++]
chunkFileNames: 'dist-external/[name].[hash].js', // [!code ++]
entryFileNames: 'dist-external/[name].[hash].js', // [!code ++]
},
},
},
plugins: [react()],
})
}

在 app1 中修改 vercel.json:

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

由于篇幅过长，其他的细节请听以后分解。

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