浏览器渲染

使用 Puppeteer 在你的 Nuxt 应用中控制和交互无头浏览器实例。

入门

通过启用 hub.browser 选项,在你的 Nuxt 项目中启用浏览器渲染

nuxt.config.ts
export default defineNuxtConfig({
  hub: {
    browser: true
  },
})

最后,通过运行以下命令安装所需的依赖项

终端
npx nypm i @cloudflare/puppeteer puppeteer
nypm 将自动检测你正在使用的包管理器并安装依赖项。

用法

在你的服务器 API 路由中,你可以使用 hubBrowser 函数获取一个 Puppeteer 浏览器实例

const { page, browser } = await hubBrowser()

在生产环境中,该实例将来自 @cloudflare/puppeteer,它是 Puppeteer 的一个分支,专门用于在 Cloudflare worker 中工作。

NuxtHub 将在响应发送时自动关闭 page 实例,并在需要时关闭或断开 browser 实例的连接。

以下是一些在你的 Nuxt 应用中使用像 Puppeteer 这样的无头浏览器的用例

  • 截取页面的屏幕截图
  • 将页面转换为 PDF
  • 测试 Web 应用
  • 收集页面加载性能指标
  • 抓取网页以获取信息检索(提取元数据)

限制

浏览器渲染目前仅在 Workers Paid 计划中可用。

为了提高生产环境中的性能,NuxtHub 将重用浏览器会话。这意味着浏览器将在每次请求后保持打开状态(持续 60 秒),如果可用,新请求将重用同一个浏览器会话,否则将打开一个新的会话。

Cloudflare 的限制是

  • 每个 Cloudflare 帐户每分钟 2 个新浏览器
  • 每个帐户 2 个并发浏览器会话
  • 如果在 60 秒内没有检测到活动(空闲超时),浏览器实例将被终止

在创建浏览器实例时,你可以通过提供 keepAlive 选项来延长空闲超时

// keep the browser instance alive for 120 seconds
const { page, browser } = await hubBrowser({ keepAlive: 120 })

最大空闲超时时间为 600 秒(10 分钟)。

一旦 NuxtHub 支持 Durable Objects,你将能够创建一个单一浏览器实例,该实例将长时间保持打开状态,并且你可以在请求之间重用它。

屏幕截图捕捉

截取网站的屏幕截图是无头浏览器的一个常见用例。让我们创建一个 API 路由来截取网站的屏幕截图

server/api/screenshot.ts
import { z } from 'zod'

export default eventHandler(async (event) => {
  // Get the URL and theme from the query parameters
  const { url, theme } = await getValidatedQuery(event, z.object({
    url: z.string().url(),
    theme: z.enum(['light', 'dark']).optional().default('light')
  }).parse)

  // Get a browser session and open a new page
  const { page } = await hubBrowser()

  // Set the viewport to full HD & set the color-scheme
  await page.setViewport({ width: 1920, height: 1080 })
  await page.emulateMediaFeatures([{
    name: 'prefers-color-scheme',
    value: theme
  }])

  // Go to the URL and wait for the page to load
  await page.goto(url, { waitUntil: 'domcontentloaded' })

  // Return the screenshot as response
  setHeader(event, 'content-type', 'image/jpeg')
  return page.screenshot()
})

在应用程序端,我们可以创建一个简单的表单来调用我们的 API 端点

pages/capture.vue
<script setup>
const url = ref('https://hub.nuxtjs.org.cn')
const image = ref('')
const theme = ref('light')
const loading = ref(false)

async function capture {
  if (loading.value) return
  loading.value = true
  const blob = await $fetch('/api/browser/capture', {
    query: {
      url: url.value,
      theme: theme.value
    }
  })
  image.value = URL.createObjectURL(blob)
  loading.value = false
}
</script>

<template>
  <form @submit.prevent="capture">
    <input v-model="url" type="url" />
    <select v-model="theme">
      <option value="light">Light</option>
      <option value="dark">Dark</option>
    </select>
    <button type="submit" :disabled="loading">
      {{ loading ? 'Capturing...' : 'Capture' }}
    </button>
    <img v-if="image && !loading" :src="image" style="aspect-ratio: 16/9;" />
  </form>
</template>

就是这样!你现在可以使用 Puppeteer 在你的 Nuxt 应用中截取网站的屏幕截图。

存储屏幕截图

你可以在 Blob 存储中存储屏幕截图

const screenshot = await page.screenshot()

// Upload the screenshot to the Blob storage
const filename = `screenshots/${url.value.replace(/[^a-zA-Z0-9]/g, '-')}.jpg`
const blob = await hubBlob().put(filename, screenshot)
了解更多关于 Blob 存储的信息。

元数据提取

另一个常见的用例是从网站提取元数据。

server/api/metadata.ts
import { z } from 'zod'

export default eventHandler(async (event) => {
  // Get the URL from the query parameters
  const { url } = await getValidatedQuery(event, z.object({
    url: z.string().url()
  }).parse)

  // Get a browser instance and navigate to the url
  const { page } = await hubBrowser()
  await page.goto(url, { waitUntil: 'networkidle0' })

  // Extract metadata from the page
  const metadata = await page.evaluate(() => {
    const getMetaContent = (name) => {
      const element = document.querySelector(`meta[name="${name}"], meta[property="${name}"]`)
      return element ? element.getAttribute('content') : null
    }

    return {
      title: document.title,
      description: getMetaContent('description') || getMetaContent('og:description'),
      favicon: document.querySelector('link[rel="shortcut icon"]')?.href
      || document.querySelector('link[rel="icon"]')?.href,
      ogImage: getMetaContent('og:image'),
      origin: document.location.origin
    }
  })

  return metadata
})

访问 /api/metadata?url=https://cloudflare.com 将返回网站的元数据

{
  "title": "Connect, Protect and Build Everywhere | Cloudflare",
  "description": "Make employees, applications and networks faster and more secure everywhere, while reducing complexity and cost.",
  "favicon": "https://www.cloudflare.com/favicon.ico",
  "ogImage": "https://cf-assets.www.cloudflare.com/slt3lc6tev37/2FNnxFZOBEha1W2MhF44EN/e9438de558c983ccce8129ddc20e1b8b/CF_MetaImage_1200x628.png",
  "origin": "https://www.cloudflare.com"
}

要存储网站的元数据,你可以使用 键值存储

或者直接利用此 API 路由上的 缓存

server/api/metadata.ts
export default cachedEventHandler(async (event) => {
  // ...
}, {
  maxAge: 60 * 60 * 24 * 7, // 1 week
  swr: true,
  // Use the URL as key to invalidate the cache when the URL changes
  // We use btoa to transform the URL to a base64 string
  getKey: (event) => btoa(getQuery(event).url),
})

PDF 生成

你也可以使用 hubBrowser() 生成 PDF,这在你想生成发票或收据时很有用。

让我们创建一个 /_invoice 页面,使用 Vue 作为模板来生成 PDF

pages/_invoice.vue
<script setup>
definePageMeta({
  layout: 'blank'
})

// TODO: Fetch data from API instead of hardcoding
const currentDate = ref(new Date().toLocaleDateString())
const items = ref([
  { name: 'Item 1', quantity: 2, price: 10.00 },
  { name: 'Item 2', quantity: 1, price: 15.00 },
  { name: 'Item 3', quantity: 3, price: 7.50 }
])
const total = computed(() => {
  return items.value.reduce((sum, item) => sum + item.quantity * item.price, 0)
})
</script>

<template>
  <div style="font-family: Arial, sans-serif; padding: 20px;">
    <div style="display: flex; justify-content: space-between; margin-bottom: 20px;">
      <div>
        <p><strong>Invoice To:</strong></p>
        <p>John Doe</p>
        <p>123 Main St</p>
        <p>Anytown, USA 12345</p>
      </div>
      <div>
        <p><strong>Invoice Number:</strong> INV-001</p>
        <p><strong>Date:</strong> {{ currentDate }}</p>
      </div>
    </div>
    <table>
      <thead>
        <tr>
          <th>Item</th>
          <th>Qty</th>
          <th>Price</th>
          <th>Total</th>
        </tr>
      </thead>
      <tbody>
        <tr v-for="(item, index) in items" :key="index">
          <td>{{ item.name }}</td>
          <td>{{ item.quantity }}</td>
          <td>${{ item.price.toFixed(2) }}</td>
          <td>${{ (item.quantity * item.price).toFixed(2) }}</td>
        </tr>
      </tbody>
    </table>
    <div style="text-align: right; margin-top: 20px;">
      <p><strong>Total: ${{ total.toFixed(2) }}</strong></p>
    </div>
  </div>
</template>

<style scoped>
table { width: 100%; border-collapse: collapse; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background-color: #f2f2f2; }
</style>

为了避免出现任何样式问题,我们建议你的 app.vue 保持尽可能简洁

app.vue
<template>
  <NuxtLayout>
    <NuxtPage />
  </NuxtLayout>
</template>

并将大多数头部管理、样式和 HTML 结构移至 layouts/default.vue 中。

最后,我们需要创建一个 layouts/blank.vue,以避免在我们的 _invoice 页面上出现任何布局

layouts/blank.vue
<template>
  <slot />
</template>

这将确保不会渲染任何头部、页脚或任何其他布局元素。

现在,让我们创建服务器路由来生成 PDF

server/routes/invoice.pdf.ts
export default eventHandler(async (event) => {
  const { page } = await hubBrowser()
  await page.goto(`${getRequestURL(event).origin}/_invoice`)

  setHeader(event, 'Content-Type', 'application/pdf')
  return page.pdf({ format: 'A4' })
})

你现在可以在你的页面中显示下载或打开 PDF 的链接

pages/index.vue
<template>
  <a href="/invoice.pdf" download>Download PDF</a>
  <a href="/invoice.pdf">Open PDF</a>
</template>