基于ASTRO的高性能SSG方案

约44分钟

引言

随着业务复杂度的日益提升,我们常常需要在单一页面内集成多个独立开发、独立部署的模块或子应用。这种前端架构在带来灵活性的同时,也引发了一系列棘手的技术挑战。特别是在性能敏感的场景下,如何高效地加载和渲染这些模块成为了一个亟待解决的问题。 在现代前端开发中,静态站点生成器(SSG)因其高性能和良好的用户体验而受到广泛关注。Astro 作为一种新兴的静态站点生成器,凭借其独特的架构和优化策略,为开发者提供了强大的工具来构建高效的静态网站。本文将探讨基于 Astro 的高性能 SSG 方案,介绍其核心概念、优势以及实际应用案例。

然而,将一个完整的预编译的Astro产物集成到现有的主站SSR框架(如Nuxt,Next等)中,并确保其性能优势得以发挥,是一个复杂的任务。本文将深入分析这一过程中的技术挑战,并提出切实可行的解决方案,以帮助开发者在不影响现有架构的前提下,成功实现基于 Astro 的高性能 SSG 方案。

前置知识

在深入了解基于 Astro 的高性能 SSG 方案之前,读者需要具备以下前置知识:

核心概念

  • 水合: 了解VUE/React等前端框架的水合原理
  • SSG:了解 SSG 的基本原理和工作流程。
  • Astro 框架:了解 Astro 的基本使用方法和特点。
  • Vite : 了解 Vite 插件的基本原理和使用方法。

水合

Dan Abramov(Create React App 和 Redux)对 Hydration 的定义是:“Hydration 就像用用户交互和事件处理程序的‘水’来浇灌‘干燥的’HTML。”

CSR客户端渲染

image-20251220124347208

代码结构

<!DOCTYPE html>
<html lang="en">
	<head>
		<meta charset="UTF-8" />
		<title>CSR Example</title>
	</head>
	<body>
		<!-- 注意这里几乎没有内容,只有一个挂载点 -->
		<div id="root"></div>

		<!-- JavaScript 负责渲染整个页面 -->
		<script src="/bundle.js"></script>
	</body>
</html>

SSR服务端渲染

在服务器端渲染(SSR)中,页面在服务器端使用 JavaScript 生成,并以 HTML 格式发送给客户端。此过程优化了服务器端的数据获取,从而提升了用户体验。在 SSR 中,初始 HTML 加载完毕后,交互所需的 JavaScript 代码会在后台加载。

image-20251220124526785

代码结构

<!DOCTYPE html>
<html lang="en">
	<head>
		<meta charset="UTF-8" />
		<title>SSR Example</title>
	</head>
	<body>
		<!-- 服务器已经渲染好 HTML -->
		<div id="root">
			<h1>欢迎来到 SSR 页面</h1>
			<p>这是服务器端渲染生成的内容。</p>
			<button onclick="alert('按钮点击事件')">点击我</button>
		</div>

		<!-- JavaScript 只负责 Hydration -->
		<script src="/bundle.js"></script>
	</body>
</html>

什么是水合

水合Hydration是指将服务器端预渲染的 HTML 代码在浏览器中实现交互的过程。换句话说,框架(React,Vue)会检查现有的 HTML 代码,并关联必要的 JavaScript 代码来激活组件,从而实现交互功能。

这样可以加快页面加载速度,因为静态内容(初始 HTML)会快速加载,交互功能随后才会启用。

这种做法结合了 SSR 和 CSR(客户端渲染)的优点:

  • 服务器端渲染(SSR) 提供了更快的首屏加载时间和更好的 SEO。
  • 客户端渲染(CSR) 提供了更丰富的交互体验。
image-20251220125059048

值得注意的是,水合作用并不直接适用于仅在客户端运行的库或框架(仅限客户端),例如仅在客户端使用的 React。

除了传统的 Web 开发和现代前端框架外,在一些其他场景中也存在类似的 Hydration 过程:

  • 静态站点生成(SSG) :在静态站点生成器(如 Gatsby、Next.js 的静态导出模式)中,初始页面会在构建时生成静态 HTML。客户端加载后,这些静态内容通过 JavaScript 变得动态和可交互。
  • 增量静态渲染(ISR) :某些现代框架(如 Next.js 提供的 ISR)允许静态页面在构建后的特定时间内动态更新,而客户端加载的过程中依然需要进行 Hydration。

SSG

什么是静态网站生成器?

静态网站生成器是一种基于原始数据和一组模板生成完整静态 HTML 网站的工具。从本质上讲,静态站点生成器自动完成对单个 HTML 页面进行编码的任务,并让这些页面提前准备好为用户提供服务。因为这些 HTML 页面是预先构建的,所以它们可以在用户的浏览器中非常快速地加载。

静态网站生成器是内容管理系统 (CMS) 的替代品,后者是另一种用于管理 Web 内容、生成网页和实施模板的工具。(模板是 Web 内容的可重用格式;开发人员使用模板来避免一遍又一遍地编写相同的格式。)

使用静态站点生成器有哪些优缺点?

优点

  • 性能因为静态站点生成器提前而不是按需(比如使用 CMS)创建网页,所以网页在用户浏览器中的加载速度稍快。
  • 定制开发人员可以创建他们想要的任何模板。它们不受 CMS 提供的字段限制,也不受 CMS 内置模板的限制。
  • 更轻的后台静态网站是轻量级的,不需要在服务器端运行那么多代码,而基于 CMS 的网站会不断地向服务器端查询内容。

Astro

Astro 是一个集多功能于一体的 Web 框架。它内置包含了你构建网站所需的一切。还有数百个不同的集成和 API 钩子可根据你的具体用例和需求定制你的项目。Astro 是最适合构建像博客、营销网站、电子商务网站这样的以内容驱动的网站的 Web 框架。Astro 以开创了一种新的前端架构而闻名,与其他框架相比它减少了 JavaScript 的开销和复杂性。如果你需要一个加载速度快、具有良好 SEO 的网站,那么 Astro 就是你的选择。

一些亮点包括:

  • 群岛:一种基于组件的针对内容驱动的网站进行优化的 Web 架构。
  • UI 无关:支持 React、Preact、Svelte、Vue、Solid、HTMX、Web 组件等等。
  • 服务器优先:将沉重的渲染移出访问者的设备。
  • 默认无 JS:让客户端更少的执行 JS ,以提升网站速度。
  • 内容集合:为你的 Markdown 内容,提供组织、校验并保证 TypeScript 类型安全。
  • 可定制:Partytown、MDX 和数百个集成可供选择。

群岛架构

“群岛” 架构的总体思想看似简单:在服务器上渲染 HTML 页面,并在高度动态的区域周围注入占位符或插槽 […] 这些区域随后可以在客户端 “激活” 成为小型独立的小部件,重用它们服务器渲染的初始 HTML。 — Jason Miller, Preact 的创造者

这种架构模式所依赖的技术也被称为 局部化或选择性激活。

渐进式水合技术在 React、Angular、Preact 和 Vue 等框架中的性能优势。在这些架构中,页面上的各个组件会随着时间的推移逐步加载和初始化。这可以通过 requestIdleCallback 实现简单的调度,也可以考虑视口可见性、交互可能性、产品价值等其他因素。

与渐进式水合作用类似,使用岛屿架构渲染页面,不仅能逐步初始化页面中较复杂的动态部分,还能分别初始化。这意味着页面的各个区域无需先加载其他内容即可实现交互。

与渐进式水合作用不同,基于岛屿架构构建的方法不需要自顶向下渲染。这是一个显著的优势,因为没有必须在子组件初始化之前初始化的外部“根”组件。页面的每个部分都是一个独立的单元,一个单元中的性能问题不会影响其他单元。

Astro的群岛架构实现

class AstroIsland extends HTMLElement {
	public Component: any
	public hydrator: any

	async connectedCallback() {
		// 等待子节点加载完成(处理 HTML 流式传输的情况)
		if (
			this.hasAttribute('await-children') &&
			document.readyState !== 'complete'
		) {
			const mo = new MutationObserver(() => {
				if (this.lastChild?.nodeValue === 'astro:end') {
					mo.disconnect()
					this.start()
				}
			})
			mo.observe(this, { childList: true })
		} else {
			this.start()
		}
	}

	async start() {
		const opts = JSON.parse(this.getAttribute('opts')!)
		const directive = this.getAttribute('client') as string // 如: 'load', 'visible'

		// 调用指令对应的加载策略 (例如:在空闲时加载、在可见时加载)
		if (Astro[directive] === undefined) {
			window.addEventListener(`astro:${directive}`, () => this.start(), {
				once: true,
			})
			return
		}

		await Astro[directive]!(
			async () => {
				const rendererUrl = this.getAttribute('renderer-url')
				// 1. 动态导入:并行加载组件代码和注水器(Hydrator)代码
				const [componentModule, { default: hydrator }] = await Promise.all([
					import(this.getAttribute('component-url')!),
					rendererUrl ? import(rendererUrl) : () => () => {},
				])

				// 2. 确定导出组件的名字(默认是 'default')
				const exportName = this.getAttribute('component-export') || 'default'
				this.Component = componentModule[exportName]
				this.hydrator = hydrator

				return this.hydrate // 返回注水函数供指令调用
			},
			opts,
			this
		)
	}

	hydrate = async () => {
		if (!this.hydrator || !this.isConnected) return

		// 递归等待:如果父级也是孤岛且未激活,先等父级注水,确保自顶向下激活
		const parentSsrIsland = this.parentElement?.closest('astro-island[ssr]')
		if (parentSsrIsland) {
			parentSsrIsland.addEventListener('astro:hydrate', this.hydrate, {
				once: true,
			})
			return
		}

		// 解析插槽 (Slots)
		const slots: Record<string, string> = {}
		for (const slot of this.querySelectorAll('astro-slot')) {
			if (slot.closest(this.tagName) !== this) continue
			slots[slot.getAttribute('name') || 'default'] = slot.innerHTML
		}

		// 解析 Props(通过前面的 revive 逻辑)
		const props = this.hasAttribute('props')
			? reviveObject(JSON.parse(this.getAttribute('props')!))
			: {}

		// 最终注水:调用渲染器(如 React, Vue 的渲染函数)将组件挂载到 DOM
		const hydrator = this.hydrator(this)
		await hydrator(this.Component, props, slots, {
			client: this.getAttribute('client'),
		})

		// 标记注水完成,触发事件
		this.removeAttribute('ssr')
		this.dispatchEvent(new CustomEvent('astro:hydrate'))
	}
}

// 注册自定义元素
if (!customElements.get('astro-island')) {
	customElements.define('astro-island', AstroIsland)
}

技术取舍

在选择使用 Astro 作为静态站点生成器时,需要考虑以下技术取舍:

  • 性能 vs. 灵活性:Astro 默认情况下提供了出色的性能,但在某些情况下,可能需要牺牲一些灵活性来实现最佳性能。
  • 集成复杂性:将 Astro 集成到现有项目中可能需要额外的工作,特别是如果现有项目已经使用了其他框架或技术栈。

在团队主应用采用 Nuxt 的情况下,经过综合考虑,我们决定采用 Astro 结合 Vue 3 来实现高性能的静态站点生成方案,以此平衡性能和开发效率。

为什么?

在主应用采用 Nuxt 框架的情况下,使用 Astro 结合 Vue 3 有以下优势:

  1. 技术栈一致:Nuxt 与 LP 都使用 Vue 3,组件、Composition API 和团队经验可以复用。但这不代表浏览器一定会复用 Nuxt 已加载的 Vue Runtime;除非额外做 external 和版本对齐,否则 Astro 产物仍可能携带自己的 Vue 客户端代码。
  2. 提升性能 : Astro 的群岛架构允许只为需要交互的部分加载 JavaScript,从而显著减少初始加载时间和提高页面响应速度。这对于提升用户体验和 SEO 非常有利。
  3. 开发效率 : 使用 Vue 3 作为前端框架,可以利用现有的 Vue 生态系统和工具链,提高开发效率。同时,Astro 提供了简洁的语法和强大的功能,使得构建静态站点更加高效。

那么问题来了

如何优雅的将Astro 集成到现有项目中?如何在不影响现有主站SSR框架(Nuxt,Next等)架构的前提下,提升网站性能? 有如下问题需要面对:

  1. 如何处理Astro编译产物与现有主站SSR框架的集成? 如下是一个最简的Astro 项目结合Vue3 编译的产物示例

       <!DOCTYPE html>
       <html lang="en"></html>
    <head>
      <meta name="generator" content="Astro v5.16.0">
      <link rel="stylesheet" href="./assets/styles.css">
    </head>
    <body>
      <div class="lp-new-area" data-app="vivaia">
        <style>
          astro-island,
          astro-slot,
          astro-static-slot {
            display: contents
          }
        </style>
        <script></script>
        <script>
    		 预编译的Astro Island 组件加载脚本
    		</script>
        <astro-island uid="PDDNA" prefix="s0"
          component-url="./assets/_app.BXJsAGJH.js" component-export="default"
          renderer-url="./assets/client.DAeD31y9.js" props="{}" ssr client="visible"
          opts="{&quot;name&quot;:&quot;VueApp&quot;,&quot;value&quot;:true}"
          await-children><!--[-->
          <div>
    			  Vue 组件渲染的html内容
    			 </div><!--]--><!--astro:end-->
        </astro-island>
      </div>
    </body>
    
    </html>
  2. 如何处理Astro编译产物中的静态资源引用路径问题? 由于Astro 编译产物中的静态资源引用路径通常是相对路径,而现有主站SSR框架(Nuxt,Next等)的静态资源路径可能不同,需要对这些路径进行处理,以确保资源能够正确加载。

  3. 如何确保Astro Vue 3 中的客户端水合逻辑能够正确执行? 需要确保Astro 生成的Vue 3 组件在客户端能够正确水合,并且不会与现有主站SSR框架(Nuxt,Next等)的水合逻辑冲突。

  4. 如何处理国际化的问题

  5. 如何处理在服务端渲染和客户端渲染使用相同Astro产物可能引发的问题?

  6. 怎么在nuxt里渲染Astro产物?

  7. 在多平台如何做到一套代码多端复用, 既能在nuxt里渲染,又能在Shopify里渲染?

方案确定

针对上述问题,我们确定了以下方案: 基于Vite 插件的方式,在构建阶段对Astro编译产物进行处理,将其转换为一个可被现有主站SSR框架(Nuxt,Next等)直接使用的格式,并且在运行时能够正确执行客户端水合逻辑。

编译流程

渲染流程

将 Astro 产物改成 Nuxt 可插入的片段

Astro 默认生成完整的 HTML 文档,其中包含 <head>、页面脚本、样式和资源地址。Nuxt 已经负责主站文档结构,不能再插入另一份 <!doctype html>。构建脚本因此会提取 LP 相关内容,输出下面这段 HTML:

<lp-container>
	<script data-exec="before-dom">
		/* beforeDom + Astro Island runtime + LP 初始化 */
	</script>
	<style data-lp-tag>
		/* 构建阶段收集后的页面样式 */
	</style>
	<lp-app>
		<astro-island><!-- Vue SSG HTML --></astro-island>
	</lp-app>
</lp-container>

各节点的用途如下:

  • lp-container 在节点移除时触发 Vue 卸载和监听器清理。
  • 前置脚本负责响应式变量、全局配置和 Astro Island URL 修正。
  • style[data-lp-tag] 保存构建时处理好的 CSS。
  • lp-app 限制样式范围,第三方样式统一增加 :where(lp-app) 前缀。
  • astro-island 保留 Astro 生成的静态 HTML、组件地址和水合信息。

Nuxt 插入这段 HTML 时,还需要提供资源前缀并执行前置脚本。删除 <lp-container> 时,LP 自己执行清理。

构建时生成统一入口

如果每个 LP 都维护一份 index.astro,入口写法很容易出现差异,例如漏掉 client:*、Layout 或初始化代码。项目在 astro:config:setup 阶段生成临时入口,再通过 injectRoute() 注入根路由:

export function injectEntryIntegration(): AstroIntegration {
	return {
		name: 'inject-entry-integration',
		hooks: {
			'astro:config:setup': async ({ config, injectRoute }) => {
				const entrypoint = await createTempAstroFile(
					config.root,
					`
          ---
          import Layout from '@lp/ui/astro/layout.astro'
          import PageRoot from './src/App.vue'
          ---
          <Layout inlineEntryStyle>
            <PageRoot client:load />
          </Layout>
        `
				)

				injectRoute({ pattern: '/', entrypoint })
			},
		},
	}
}

所有 LP 都从 src/App.vue 开始,业务项目不需要重复维护 Astro 路由。<PageRoot client:load /> 也保证产物中包含可水合的 Astro Island。

没有 client:* 指令的框架组件只输出静态 HTML 和 CSS。client:load 会在页面加载后下载组件代码并水合。这里对 App.vue 使用 client:load,因为它负责启动整个 LP;子组件仍可单独配置延迟水合。

构建完成后重组 HTML

Astro 先生成完整页面。lpSSGIntegration() 在 astro:build:done 阶段读取 dist/<locale>/index.html,处理以下内容:

  1. 收集 Astro Island 的 classic script。
  2. 收集页面中的 style 内容。
  3. 提取完整的 <lp-app>。
  4. 编译 beforeDom、flexible()、initLpConfig() 和 initAstroApp()。
  5. 重组为 <lp-container> 片段,并额外生成可独立打开的 _preview.html。

下面是简化后的处理代码:

const originHtml = await readFile(indexPath, 'utf8')
const $ = cheerio.load(originHtml)

const islandRuntime = collectClassicScripts($)
const styles = collectStyles($)
const lpApp = extractLpApp(originHtml)
const entry = await buildBrowserEntry()

const fragment = `
  <lp-container>
    <script data-exec="before-dom">${islandRuntime}${entry}</script>
    <style data-lp-tag>${styles}</style>
    ${lpApp}
  </lp-container>
`

await writeFile(indexPath, fragment)

这里使用 HTML Parser 收集节点,因为产物中包含内联脚本、模块脚本、CSS 顺序和 Astro 生成的资源依赖。对整份 HTML 做字符串替换,遇到标签属性或输出格式变化时容易漏内容。

当前实现有一个已知限制:代码会收集并编译 moduleScriptContent,但 VIVAIA 主站分支没有把它写回最终片段。现在 ~entry 只生成样式导入,Vue Island 使用独立资源链,所以现有产物可以运行。如果以后向 ~entry 增加业务 JavaScript,需要先补上模块脚本注入。

Nuxt 服务端读取 LP 数据

主站没有直接从浏览器请求 OSS。页面先访问 /server-api/campaign/:id,Nitro 路由再读取构建后上传的 JSON 文件。

请求参数只允许字母、数字、连字符和下划线,避免把 ..、/ 等路径内容拼进 OSS 地址。语言参数只允许字母和连字符。

const idRegex = /^[a-zA-Z0-9-_]+$/
const langRegex = /^[a-zA-Z-]+$/
const defaultLang = 'en'

export default defineEventHandler(async (event) => {
	const id = getRouterParam(event, 'id')
	const rawLang = getQuery(event).lang
	let lang = typeof rawLang === 'string' ? rawLang : defaultLang
	const targetBase = useRuntimeConfig().server.lpOSSBaseURL

	if (!id || !idRegex.test(id) || !langRegex.test(lang)) {
		return createHttpError(event, {
			status: 400,
			message: 'Invalid campaign parameter',
		})
	}

	const getRequestPath = () => joinURL(targetBase, id, `${lang}.json`)
	let lpData: LPData | null = await $fetch<LPData>(getRequestPath()).catch(
		() => null
	)

	if (!lpData && lang !== defaultLang) {
		lang = defaultLang
		lpData = await $fetch<LPData>(getRequestPath()).catch(() => null)
	}

	if (!lpData) {
		return createHttpError(event, {
			status: 404,
			message: 'LP data not found',
		})
	}

	const {
		title,
		description,
		keywords,
		template,
		contentPath,
		showPageFooter,
		showPageHeader,
		showPagePolicy,
	} = lpData

	return {
		status: 200,
		data: {
			path: joinURL(id, contentPath, lang),
			template,
			title,
			description,
			keywords,
			showPageFooter,
			showPageHeader,
			showPagePolicy,
		},
	}
})

path 是资源目录,不是页面路由。页面会把它拼到静态资源域名后面,供 Astro Island 加载组件和 Renderer。

生产环境还会设置 720 小时的缓存:

if (!import.meta.dev) {
	setHeaders(event, {
		'Cache-Control': `max-age=${720 * 3600}`,
		'Edge-Cache-Tag': 'campaign-server-api',
		Expires: expiresDate,
	})
}

LP 发布后内容很少变动,请求通常由 CDN 缓存处理。缓存未命中时才会进入 Node 服务并读取 OSS。

Nuxt 页面处理 SSR 和 SPA 两种加载方式

页面使用 useAsyncData() 请求 Nitro 路由,并通过 v-html 输出模板:

<template>
	<div ref="lpContainerRef">
		<div
			v-if="!fetchError"
			:id="containerId"
			v-html="htmlContent"
			class="campaign-wrapper"
		></div>
	</div>
</template>

SSR 和 SPA 对模板数据的处理不同。SSR 已经把 LP 写进页面 HTML,没有必要再把同一份字符串放进 #__NUXT_DATA__。服务端给 htmlContent 赋值后,会从 useAsyncData() 的返回值中删除 template:

const htmlContent = ref(getInitialHtml())

const { data: page, error: fetchError } = await useAsyncData(
	`campaign-${id.value}`,
	async () => {
		const res = await $fetch(`/server-api/campaign/${id.value}`, {
			query: { lang: lang.value },
		})
		const data = res.data || null
		if (!data) return null

		if (import.meta.server) {
			htmlContent.value = data.template || ''
			const { template, ...rest } = data
			return rest
		}

		return data
	}
)

客户端开始 Hydration 时,page 来自 Nuxt payload,其中已经没有 template。为了让 htmlContent 的初始值与服务端 DOM 一致,页面会读取现有容器内容:

const getInitialHtml = () => {
	if (import.meta.client && nuxtApp.isHydrating) {
		const element = document.getElementById(containerId.value)
		return element ? element.innerHTML : ''
	}
	return ''
}

如果客户端初始值为空,Vue Hydration 时看到的状态与服务端 HTML 不一致,可能重新处理整段 LP。这里直接复用现有 DOM,同时避免把大段模板重复序列化到 payload。

页面还会在 <head> 中提前写入资源前缀和 SSR 标记:

useHead({
	script: [
		{
			key: 'lp-config',
			innerHTML: `
        window.__lpConfig__ = window.__lpConfig__ || {};
        window.__lpConfig__.ssrRendered = ${import.meta.server};
        window.__lpConfig__.prefix = ${JSON.stringify(prefix.value)};
      `.replace(/\s+/g, ''),
		},
	],
})

SSR 返回的 <lp-container> 会随主文档一起解析,内部脚本可以按 HTML 顺序执行。ssrRendered 为 true 时,onMounted() 不再重复处理模板。

SPA 跳转时,接口返回值中包含 template。v-html 不会执行字符串中的 <script>,页面先用 DOMParser 分离脚本,再按标记控制执行顺序:

const processContent = async () => {
	if (!page.value?.template) return

	window.__lpConfig__ ||= {}
	window.__lpConfig__.prefix = prefix.value

	const { html, scripts } = parseHtmlAndScripts(page.value.template)
	const beforeDomScripts = scripts.filter(
		(script) => script.exec === 'before-dom'
	)
	const afterDomScripts = scripts.filter(
		(script) => script.exec === 'after-dom'
	)

	await executeScripts(beforeDomScripts)
	await nextTick()
	htmlContent.value = html
	await nextTick()
	await executeScripts(afterDomScripts)
}

executeScripts() 会创建新的 <script> 节点并追加到页面容器。普通脚本按原顺序执行,外部脚本等待 load 或 error 后再继续。构建产物中的启动脚本标记为 data-exec="before-dom",所以 Astro Island 注册和资源地址处理会先执行,依赖 LP DOM 的脚本则放在 after-dom。

处理嵌入后的资源路径

假设 Astro 产物位于:

https://cdn.example.com/lp/summer/en/assets/App.js

但页面实际地址是:

https://www.example.com/products/shoes

如果片段里仍然是 ./assets/App.js,浏览器会按当前文档地址解析,最终请求:

https://www.example.com/products/assets/App.js

这个请求地址不包含 LP 的 CDN 目录,动态组件会返回 404。项目分别在构建阶段和运行阶段处理路径。

构建阶段使用相对 base:

export function relativeBasePlugin(): Plugin {
	return {
		name: 'vite-plugin-relative-base',
		config() {
			return {
				base: './',
				build: {
					cssCodeSplit: false,
					modulePreload: false,
				},
			}
		},
	}
}

base: './' 让产物可以部署到不同目录。关闭 modulePreload 可以避免浏览器按主站 URL 提前请求错误的 preload 地址。关闭 CSS 拆分后,样式不再通过多个 chunk 继续引用其他相对路径。

运行阶段由主站根据接口返回的 page.path 生成资源前缀:

const prefix = computed(() => {
	if (!page.value) return ''
	return `https://staticdn.vivaia.com/promotion/${useRuntimeConfig().public.modeName}/${page.value.path}`
})

initAstroApp() 再使用 window.__lpConfig__.prefix 处理 Astro Island 的资源属性:

const urlKeys = [
	'component-url',
	'renderer-url',
	'hydration-url',
	'before-hydration-url',
]

AstroIsland.prototype.getAttribute = function (name) {
	const value = originalGetAttribute.call(this, name)
	const prefix = window.__lpConfig__?.prefix

	if (prefix && urlKeys.includes(name) && value && !value.startsWith(prefix)) {
		return `${prefix}${value}`
	}

	return value
}

代码没有重写页面中所有 src 和 href,因为业务链接和图片可能使用其他 CDN。只处理 Astro 动态 import 使用的几个属性即可。

Astro 与 Vue 的两级水合

Nuxt 管理主站组件树,Astro Island 只管理 <lp-app> 内部的 Vue 组件树。两个应用使用不同的挂载节点,不会互相接管 DOM。

LP 内部使用两级水合:

<script setup lang="ts">
import { hydrateOnVisible } from 'vue'

const ProductStory = defineDynamicComponent({
	loader: () => import('./ProductStory.vue'),
	hydrate: hydrateOnVisible(),
})
</script>

Astro 的 client:load 先水合 App.vue。Vue 异步组件再根据自己的配置决定水合时间:

  • hydrateOnVisible():组件进入视口时水合。
  • hydrateOnIdle():浏览器空闲时水合。
  • 未配置延迟策略的首屏组件跟随根组件水合。

如果所有组件都静态 import 到 App.vue,首屏仍会下载这些业务代码。首屏 Hero 也不适合统一改成 hydrateOnVisible(),否则 Intersection Observer 触发前无法交互。项目按组件位置和交互时机配置水合策略。

Nuxt SSR 直接输出片段时,浏览器会按文档顺序执行脚本。SPA 跳转使用前面展示的 processContent(),先运行 before-dom 脚本,再插入 HTML 和运行 after-dom 脚本。

按语言分别构建

营销 LP 的语言在发布前已经确定。项目为每个 locale 单独执行构建:

for (const locale of mainSiteLang) {
	await runBuild(locale) // VITE_LOCALE=<locale> astro build
}

Vite 插件在编译阶段把 $t('hero.title') 和 VITE_LOCALE 替换为当前语言,最终形成独立目录:

dist/
├── en/
├── de/
├── fr/
├── es/
└── it/

每个目录只包含当前语言的数据,因此:

  • 每个页面只包含当前语言,不下载运行时 i18n 和其他语言文案。
  • 文案缺失可以在构建阶段发现。
  • CDN 可以按语言目录独立发布和缓存。

语言数量会直接增加构建时间。当前实现使用串行构建,各语言的日志、缓存目录和输出过程不会互相干扰。需要并行时,应先为每个语言隔离缓存和输出目录。

同时支持 SSR 首次访问和客户端路由切换

同一份片段既用于 Nuxt SSR 首次访问,也用于客户端路由切换。初始化和清理代码需要支持重复进入页面。

1. 初始化要可重复

Custom Element 只能注册一次,Astro Island 的原型也不能每次进入页面都重复 patch。因此代码会先检查:

if (!customElements.get('lp-container')) {
	customElements.define('lp-container', LpContainer)
}

if (!AstroIsland[ASTRO_ISLAND_PATCH_SYMBOL]) {
	patchAstroIslandUrl()
	AstroIsland[ASTRO_ISLAND_PATCH_SYMBOL] = true
}

2. 离开页面时卸载 Vue App

initVueApp() 把 app.unmount() 注册到 window.__lpConfig__.unmountFns。当 <lp-container> 从 DOM 中移除时,disconnectedCallback() 统一执行这些清理函数。

主站页面的 onBeforeUnmount() 还会删除动态插入的 script[data-lp="true"],将 ssrRendered 重置为 false,并触发 fengniao_lp:destroy。如果不执行这些清理,多次路由切换后会残留脚本、事件监听、Observer 和动画实例。

3. 浏览器专用逻辑不能在构建时直接执行

window、document、customElements 等 API 放在客户端初始化阶段执行。组件在服务端和客户端首次渲染时需要输出相同内容,否则会产生 hydration mismatch。

Nuxt 与 Shopify 使用不同产物

Nuxt 主站可以配合 LP 设置资源地址、执行脚本和处理卸载。Shopify 等第三方站点无法保证这些接入条件,宿主样式还可能影响 LP。项目复用 App.vue、业务组件和基础工具,再根据宿主生成不同产物:

场景主站语言JP/KR 非主站
构建器Astro SSGVite + Vue Custom Element
产物index.html 片段 + assets/单个自包含 HTML
样式隔离:where(lp-app) 选择器前缀Shadow DOM
水合Astro Island + Vue Lazy HydrationVue Custom Element 客户端挂载
资源策略相对路径 + 主站 prefixCSS/业务代码内联,公共依赖 CDN external

非主站入口会创建外层 <lp-app> 并附加 Shadow Root,再把 App.vue 注册为 Vue Custom Element。组件 CSS 与公共 CSS 被收集成字符串,写入 Custom Element 内部的 Shadow Root。

const lpApp = document.createElement('lp-app')
const outerRoot = lpApp.attachShadow({ mode: 'open' })

const LpElement = defineCustomElement(App)
customElements.define(uniqueElementName, LpElement)

const element = document.createElement(uniqueElementName)
element.shadowRoot.append(style)
outerRoot.append(element)

主站产物保留 SSG HTML 和按需水合。第三方站点使用单文件和 Shadow DOM,部署更简单,也能隔离宿主样式,但 Custom Element 会增加客户端启动成本。

Shadow Root 内的 CSS 无法修改宿主页面的 .post-title、.section-spacing 等节点。这类修正样式写入 document.head。LP reset、组件样式和语言样式留在 Shadow Root 内,避免影响宿主页面。

可以验证的性能变化

这套构建方式带来以下变化:

  1. 首屏内容在构建时已经输出为 HTML,浏览器不用等待 Vue 完成首次 CSR 才看到主体。
  2. 不需要交互的内容不发送组件 JavaScript。
  3. 首屏外的 Vue 异步组件可以延迟到可见或空闲时水合。
  4. 每个语言产物只包含当前语言数据。
  5. 主站使用代码分割,第三方宿主则用单文件减少部署和路径不确定性。

仍然需要关注以下开销:

  • App.vue client:load 仍然会下载 Astro Island、Vue Renderer 和根组件依赖。
  • 如果主站与 LP 各自打包 Vue,浏览器可能下载两份 Runtime。
  • 大图、视频、埋点和第三方脚本仍可能比框架代码更影响 LCP、INP。
  • Shadow DOM 单文件会减少部署文件数量,但不保证首屏 JavaScript 最小。

验证时需要同时检查构建产物和页面指标。Lighthouse 用于发现分数回退,Network 和 Performance 面板用于确认耗时来自网络、脚本执行、布局还是图片解码。

常见故障排查

页面有 HTML,但没有交互

先检查 astro-island 是否带有 client 属性,再检查 Island runtime 是否执行。若片段来自客户端动态插入,还要确认宿主是否主动执行了脚本。

动态组件请求 404

检查 component-url、renderer-url 的最终值,以及 window.__lpConfig__.prefix 是否指向当前语言的资源目录。不要只看 DOM 中的原始相对地址,因为运行时可能通过 getAttribute() 加上前缀。

页面能运行,但主站样式被改坏

检查第三方 CSS 是否加了 :where(lp-app) 前缀。对于不可控宿主,再确认组件样式是否真的写入 Shadow Root,而不是停留在 document 样式表中。

开发正常,某个语言生产异常

优先比较对应 locale 的独立构建产物。语言和主站类型是编译时状态,不能用 DEV、PROD 推导 JP/KR 是否属于非主站。

首次进入正常,多次切换后越来越卡

检查 <lp-container> 移除时是否触发 Vue unmount,并清理 Observer、事件监听和动画实例。旧页面没有完成清理时,这些对象会随着路由切换不断增加。

最小验证清单

下面的命令覆盖主站、非主站和性能检查:

# 主站默认语言
pnpm build

# 非主站两条独立链路
pnpm build:jp
pnpm build:kr

# 性能回归
pnpm cli lighthouse

然后直接检查产物:

  • 主站 index.html 只包含可嵌入的 <lp-container>,_preview.html 可以独立打开。
  • assets/ 中保留 App、Vue Renderer 和异步组件 chunk,引用路径不是站点根绝对路径。
  • reset、公共样式和组件样式没有重复出现。
  • JP/KR 输出为单 HTML,LP 样式位于 Shadow Root,宿主修正只在 document.head 出现一次。
  • 删除 LP 容器后,没有遗留事件、Observer 和 Vue App。

总结

这个项目对 Astro 默认产物做了以下处理:

  1. 通过 Astro Integration 生成统一入口。
  2. 在 astro:build:done 中提取脚本、样式和 <lp-app>,生成 Nuxt 可插入的片段。
  3. Nuxt 服务端从 OSS 读取语言 JSON,并向页面返回模板、SEO 信息和资源目录。
  4. SSR 直接输出模板且不写入 payload,SPA 跳转时手动执行片段中的脚本。
  5. 使用相对 base 和运行时 prefix 修正 Astro Island 的资源地址。
  6. 使用 <lp-container> 管理初始化和卸载。
  7. Nuxt 主站保留 SSG 和延迟水合,Shopify 等第三方站点使用 Shadow DOM 单文件。

接入其他宿主时,需要确认资源地址、脚本执行方式、样式作用范围和页面卸载流程。缺少其中任何一项,都可能出现资源 404、只有静态 HTML、样式污染或重复监听。

建议更改

上次更新于: 2026-08-28 03:22