基于ASTRO的高性能SSG方案
引言
随着业务复杂度的日益提升,我们常常需要在单一页面内集成多个独立开发、独立部署的模块或子应用。这种前端架构在带来灵活性的同时,也引发了一系列棘手的技术挑战。特别是在性能敏感的场景下,如何高效地加载和渲染这些模块成为了一个亟待解决的问题。 在现代前端开发中,静态站点生成器(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客户端渲染

代码结构
<!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 代码会在后台加载。

代码结构
<!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) 提供了更丰富的交互体验。

值得注意的是,水合作用并不直接适用于仅在客户端运行的库或框架(仅限客户端),例如仅在客户端使用的 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 有以下优势:
- 技术栈一致:Nuxt 与 LP 都使用 Vue 3,组件、Composition API 和团队经验可以复用。但这不代表浏览器一定会复用 Nuxt 已加载的 Vue Runtime;除非额外做 external 和版本对齐,否则 Astro 产物仍可能携带自己的 Vue 客户端代码。
- 提升性能 : Astro 的群岛架构允许只为需要交互的部分加载 JavaScript,从而显著减少初始加载时间和提高页面响应速度。这对于提升用户体验和 SEO 非常有利。
- 开发效率 : 使用 Vue 3 作为前端框架,可以利用现有的 Vue 生态系统和工具链,提高开发效率。同时,Astro 提供了简洁的语法和强大的功能,使得构建静态站点更加高效。
那么问题来了
如何优雅的将Astro 集成到现有项目中?如何在不影响现有主站SSR框架(Nuxt,Next等)架构的前提下,提升网站性能? 有如下问题需要面对:
-
如何处理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="{"name":"VueApp","value":true}" await-children><!--[--> <div> Vue 组件渲染的html内容 </div><!--]--><!--astro:end--> </astro-island> </div> </body> </html> -
如何处理Astro编译产物中的静态资源引用路径问题? 由于Astro 编译产物中的静态资源引用路径通常是相对路径,而现有主站SSR框架(Nuxt,Next等)的静态资源路径可能不同,需要对这些路径进行处理,以确保资源能够正确加载。
-
如何确保Astro Vue 3 中的客户端水合逻辑能够正确执行? 需要确保Astro 生成的Vue 3 组件在客户端能够正确水合,并且不会与现有主站SSR框架(Nuxt,Next等)的水合逻辑冲突。
-
如何处理国际化的问题
-
如何处理在服务端渲染和客户端渲染使用相同Astro产物可能引发的问题?
-
怎么在nuxt里渲染Astro产物?
-
在多平台如何做到一套代码多端复用, 既能在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,处理以下内容:
- 收集 Astro Island 的 classic script。
- 收集页面中的 style 内容。
- 提取完整的
<lp-app>。 - 编译
beforeDom、flexible()、initLpConfig()和initAstroApp()。 - 重组为
<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 SSG | Vite + Vue Custom Element |
| 产物 | index.html 片段 + assets/ | 单个自包含 HTML |
| 样式隔离 | :where(lp-app) 选择器前缀 | Shadow DOM |
| 水合 | Astro Island + Vue Lazy Hydration | Vue Custom Element 客户端挂载 |
| 资源策略 | 相对路径 + 主站 prefix | CSS/业务代码内联,公共依赖 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 内,避免影响宿主页面。
可以验证的性能变化
这套构建方式带来以下变化:
- 首屏内容在构建时已经输出为 HTML,浏览器不用等待 Vue 完成首次 CSR 才看到主体。
- 不需要交互的内容不发送组件 JavaScript。
- 首屏外的 Vue 异步组件可以延迟到可见或空闲时水合。
- 每个语言产物只包含当前语言数据。
- 主站使用代码分割,第三方宿主则用单文件减少部署和路径不确定性。
仍然需要关注以下开销:
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 默认产物做了以下处理:
- 通过 Astro Integration 生成统一入口。
- 在
astro:build:done中提取脚本、样式和<lp-app>,生成 Nuxt 可插入的片段。 - Nuxt 服务端从 OSS 读取语言 JSON,并向页面返回模板、SEO 信息和资源目录。
- SSR 直接输出模板且不写入 payload,SPA 跳转时手动执行片段中的脚本。
- 使用相对 base 和运行时 prefix 修正 Astro Island 的资源地址。
- 使用
<lp-container>管理初始化和卸载。 - Nuxt 主站保留 SSG 和延迟水合,Shopify 等第三方站点使用 Shadow DOM 单文件。
接入其他宿主时,需要确认资源地址、脚本执行方式、样式作用范围和页面卸载流程。缺少其中任何一项,都可能出现资源 404、只有静态 HTML、样式污染或重复监听。
上次更新于: 2026-08-28 03:22