我们从 Vue 到 Alpine.js 的旅程
Category(分类): Other Status: 已整理
作者:Tim Kleyersburg
本文保留原文关于一个电商站点从 Vue 迁移到 Alpine.js、分析 JavaScript 主线程开销和使用渐进式增强的实践。原文项目发生在 2019—2021 年,文中的 Vue 2、Alpine.js 2.8、Spruce、TTI 和 Lighthouse 分数都属于当时的技术背景,不能直接当作今天的默认方案。
原文配图已下载到本文同目录的
images/文件夹。配图中的 Lighthouse 报告只代表当时的实验环境,不代表当前网站或当前工具的结果。
一、问题
在 2019 年底,我们为一位客户重新发布了一个电子商务网站。这次变动很大,不仅影响整体设计和模板,也涉及前端架构;后端基本没有改变。
客户的主要需求是:
- 优化 PageSpeed 指标;
- 提高可用性,从而提高转化率。
经过数月实施,客户和我们都对结果满意。我们在当时的 Lighthouse 四个类别中都达到了绿色评级,转化率也有明显提升。后来 Lighthouse 更新了性能评分计算方式,原来的分数从绿色降到了红色。
这里首先要补充一个今天仍然适用的判断:实验室评分变化不等于真实用户体验突然变差。Lighthouse 的版本、模拟设备、网络、缓存状态、CPU 限制、第三方脚本和运行时机都会影响一次报告。发布决策还应该结合 CrUX、RUM、转化率和错误率,而不是只追逐一个分数。
当时 Google 将 TTFB、资源体积、CSS、网页字体、TTI 和 LCP 等因素纳入性能评估。今天的指标体系已经发生变化:TTI 从未是 Core Web Vital,它是旧版 Lighthouse 的实验室指标,如今也不再是主要推荐指标;INP 已于 2024 年取代 FID 成为交互指标。LCP、INP、CLS 应结合真实用户数据观察。
二、分析过程
我们需要更多数据。此前我们没有认真关注更深层的性能指标,于是开始使用:
- Chrome DevTools 的 Lighthouse 面板;
- 命令行或 CI 中运行的 Lighthouse;
- DevTools 的 Performance 面板,用来观察主线程、长任务、布局和网络;
- 后续补充真实用户监控,确认实验室优化是否真的改善用户体验。

Lighthouse 的“脚本评估”或 Main thread 工作很有价值:它能帮助我们发现 JavaScript 下载、解析、编译、执行以及框架初始化花费了多少时间。但不要把“脚本评估时间”直接等同于用户的 INP;INP 覆盖整个页面生命周期中的真实交互,脚本只是可能的原因之一。

三、我们当时的设置
重发布后,我们使用 Vue 2 作为 JavaScript 框架,Tailwind CSS 作为 CSS 框架,Symfony Encore(Webpack)负责打包。
站点不是 SPA。我们把 Vue 根实例挂载到一个 #app 元素上,再利用无渲染组件提供交互。服务器端使用 Twig 生成大部分 HTML 和初始数据,不需要为每个组件额外设计 API:
<notepad-star
:product-id="{{ product_id }}"
:initial-star="{{ is_stared(product_id) ? 'true' : 'false' }}"
>
<div>
<button type="button" @click.prevent="toggle">Toggle</button>
</div>
</notepad-star>
product_id 是服务器端变量,is_stared(product_id) 是 Twig 函数,最终值作为 props 传入组件。真实项目必须保证服务器输出经过正确 HTML 转义,并避免把未验证的字符串直接拼进属性或脚本。
今天看这套架构
这种模式本质上是“服务器渲染 HTML + 局部客户端增强”,并不一定需要完整 SPA。今天可以继续使用这种思路,也可以选择:
- Vue 3 的局部挂载或组件岛;
- Nuxt、Astro 等框架提供的 SSR/SSG 与 islands;
- Alpine.js、原生 Web Components 或少量原生 JavaScript 做轻量交互;
- 对需要复杂状态、路由、表单和数据缓存的部分使用完整客户端框架。
重点不是把所有页面都塞进同一个根实例,而是让交互边界、数据边界和渲染边界尽量清晰。
四、生产环境与问题定位
我们当时的流程大致是:
- 在 Chrome 中生成性能报告;
- 研究报告和 Performance trace;
- 修改一个或一组变量;
- 在相同的网络、设备和缓存条件下重新测试;
- 用真实用户指标确认是否值得保留这次修改。
第一步是临时去掉脚本标签,观察指标变化:

结果很明显:整个站点都挂载 Vue 根实例,即使页面只有少量交互,也需要让 Vue 处理大量可见 DOM。主页大约有 4500 个节点,框架初始化、模板编译、响应式建立和组件生命周期都可能增加主线程工作。
这并不意味着“DOM 节点多就一定不能用 Vue”,也不意味着 Alpine 一定更快。真正需要比较的是:
- 初始 JavaScript 的传输、解析和执行成本;
- 需要被增强的 DOM 范围;
- 交互发生时的事件处理和更新范围;
- 服务器渲染、hydration 或客户端挂载的成本;
- 设备性能、网络和第三方脚本的影响。
五、我们需要优化什么
原文列出的方向仍然有价值,可以重新表达为:
- 让关键资源尽早且只加载一次;
- 减少初始 JavaScript 和长任务;
- 让交互尽快提供视觉反馈,改善 INP;
- 减少首屏无关组件的初始化;
- 用真实用户数据确认 LCP、INP、CLS 是否改善。
1. 预加载和预连接要谨慎
我们当时测试了预加载和预连接的不同组合。今天不应把“预加载越多越快”当作结论:
preload只适合浏览器较晚发现、且当前页面一定会使用的关键资源;preconnect会提前消耗连接资源,只对确实很快要访问的跨源 origin 使用;- Google Tag Manager、广告和分析脚本通常不是首屏关键资源,不应为了提高 Lighthouse 分数而盲目
preload; - 字体预加载要配合正确的
as="font"、格式和crossorigin,否则可能重复下载; - CSS、JavaScript、图片和字体应优先通过响应式、代码分割、合理缓存和资源优先级解决。
可以用 DevTools Network 的 Initiator、Priority 和 Timing 检查资源是否被重复请求;不要只根据 Lighthouse 的一条建议复制标签。
2. 优化主线程
初始 JavaScript 的成本通常来自多个部分:
- 下载和解压;
- JavaScript 解析、编译和执行;
- 框架初始化、响应式代理和组件生命周期;
- 模板编译或 hydration;
- 第三方脚本;
- 大量 DOM 的样式计算、布局和绘制。
常见措施包括:
- 删除未使用依赖和重复 polyfill;
- 按路由、组件和功能做代码分割;
- 使用动态
import()延迟低频功能; - 将非关键第三方脚本延后,明确它们的性能和隐私影响;
- 拆分长任务,把不阻塞下一帧的工作放到空闲时段或 Worker;
- 只让需要交互的 DOM 进入客户端增强范围;
- 给列表、图片和广告预留尺寸,减少 CLS;
- 优先修复真实用户最常遇到的慢交互,而不是只优化首页加载截图。
六、Vue 版本与运行时构建
原文讨论了 Vue 运行时构建和包含模板编译器的构建。这个区分仍然存在,但现代构建工具通常会在构建阶段预编译单文件组件。
- 运行时构建:包含响应式和渲染能力,不包含运行时模板编译器,适合已经被构建工具编译的模板;
- 完整构建:额外包含模板编译器,适合运行时才需要把字符串模板编译成渲染函数的场景,但体积和执行成本更高;
- Vue 3 + Vite/Rollup/Webpack:通常能够在构建时完成模板编译,并通过 tree-shaking 和代码分割减少交付内容。
Vue 2 已于 2023 年 12 月 31 日结束官方维护。新项目应使用 Vue 3;存量 Vue 2 项目应评估迁移、第三方扩展兼容性和安全支持,而不能把“还能从 CDN 下载”理解成仍在维护。
迁移到 Vue 3 也不是自动的性能优化。若仍然把整个站点和大量静态 DOM 挂到一个根实例上,换版本可能只改变部分开销,不能替代渲染边界和加载策略的重新设计。
七、为什么考虑 Alpine.js
我们整理了站点已有的交互组件:
- 实时搜索;
- 动态侧边栏;
- 弹出菜单;
- 模态框;
- 购物车和大型菜单;
- 少量不需要单独组件的全局操作。
这些功能需要一定的响应式、事件通信和状态管理,但并不需要完整 SPA 的路由和客户端数据层。于是我们用 Alpine.js 做概念验证,重建了滑动导航、动态购物车和主菜单等较复杂组件。如果这些组件能满足需求,其他轻量交互也有机会采用同样的方案。
经过约一天的验证,我们得到满意结果。但这只是该项目的结果,不是“Alpine.js 永远比 Vue 快”的证明。Alpine 更适合在已有 HTML 上添加局部行为;复杂表单、编辑器、图表、路由、跨页面缓存和大型团队协作仍可能更适合 Vue、React 或其他完整方案。
八、从 Alpine.js 2 到 Alpine.js 3
原文依赖 Alpine.js 2.8 和 Spruce。今天新代码应优先使用 Alpine.js 3 的 Alpine.store(),不要把 Spruce 当作默认状态管理方案。迁移时还需要逐项确认插件、指令、构建方式和事件语法。
1. 组件
组件可以由返回状态和方法的函数定义,再通过 x-data 初始化:
// components/modal.js
import { dispatch } from '@/helper/customEvent'
const modal = () => ({
open: false,
name: null,
instantDisplay: false,
init() {
this.open = this.instantDisplay === true
},
openModal(event) {
if (event.detail?.payload?.name !== this.name) {
return
}
this.open = true
},
close() {
this.open = false
dispatch('modal-close', { name: this.name })
}
})
export default modal
构建入口还需要把它注册为 Alpine 组件,例如 Alpine.data('modal', modal),再启动 Alpine。对应的 HTML 可以是:
<div
x-data="modal"
@modal-open.window="openModal($event)"
x-show="open"
x-cloak
>
<button type="button" @click="close">关闭</button>
</div>
x-cloak 用于避免 Alpine 初始化前短暂显示内容;模态框还应处理焦点、键盘 Escape、背景滚动、ARIA 属性和关闭后的焦点回收,不能只依赖 x-show。
2. 事件常量
原文用 enum 文件保存事件名。这个思路仍然有用,可以避免字符串散落在代码库中:
export const MODAL_OPEN = 'modal-open'
export const MODAL_CLOSE = 'modal-close'
export const SEARCH_RESULT = 'search-result'
3. CustomEvent helper
Alpine 可以监听窗口级事件,标准 CustomEvent 足以完成简单通信:
export function dispatch(name, payload = null, originalEvent = null) {
window.dispatchEvent(
new CustomEvent(name, {
detail: {
payload,
originalEvent
}
})
)
}
如果在 HTML 内联属性中调用,应明确把函数暴露到 window,或改用 Alpine 表达式,避免模块函数在全局不存在:
window.dispatch = dispatch
<button type="button" @click="dispatch('modal-open', { name: 'cart' })">
打开购物车
</button>
事件名、payload 结构和来源应有明确约定。跨页面或复杂数据流不要无限增加全局事件,否则排查依赖关系会比组件通信更困难。
4. 内容提供器
内容提供器可以被看作无状态的客户端 API 层。它负责请求、检查 HTTP 状态、解析 JSON,再把结果交给组件:
import { dispatch } from '@/helper/customEvent'
export async function getResultFor(searchTerm, signal) {
const url = new URL('/search', window.location.origin)
url.searchParams.set('q', searchTerm)
const response = await fetch(url, { signal })
if (!response.ok) {
throw new Error(`Search failed: ${response.status}`)
}
const result = await response.json()
dispatch('search-result', result)
return result
}
实际项目还要处理竞态请求、取消旧请求、加载状态、错误提示、缓存、鉴权和服务端返回的数据校验。把所有逻辑写成全局函数并不会自动解决这些问题。
5. 全局 store
Alpine 3 提供了官方的 Alpine.store():
document.addEventListener('alpine:init', () => {
Alpine.store('megamenu', {
activeId: null,
toggle(id) {
this.activeId = this.activeId === id ? null : id
}
})
})
模板中通过 $store 访问:
<nav x-data>
<button type="button" @click="$store.megamenu.toggle('products')">
产品
</button>
<div x-show="$store.megamenu.activeId === 'products'">
产品菜单
</div>
</nav>
Store 适合少量跨组件状态;不要把所有服务器数据、缓存、表单和路由都塞进一个全局对象。需要复杂数据同步时,应使用明确的数据层或完整框架方案。
九、性能结果应该怎么读
原文在开发环境中看到了大约 15–20 个百分点的提升,后来发布前和发布后又出现明显波动:


这种波动并不奇怪。Lighthouse 结果会受到以下因素影响:
- 测试设备和 CPU 限制;
- 网络延迟、吞吐和丢包;
- 首次访问还是缓存访问;
- 第三方脚本、广告和分析服务是否响应;
- 测试时间、服务器负载和 CDN 调度;
- Lighthouse 与浏览器版本的差异。
现在更建议按下面的顺序判断:
- 用 Lighthouse、DevTools Performance 和 WebPageTest 等实验室工具定位原因;
- 用
web-vitals收集 LCP、INP、CLS 等真实用户指标; - 按页面、设备、地区、版本观察 p75 和失败率;
- 结合业务转化率、表单完成率和错误率评估收益;
- 把关键性能预算放入 CI,但避免让单次实验室噪声阻塞所有发布。
Core Web Vitals 的常用目标是 p75:LCP ≤ 2.5 秒、INP ≤ 200 毫秒、CLS ≤ 0.1。它们是用户体验目标,不是某个框架的专属指标。TBT 可以在没有可靠交互脚本的实验室测试中作为 INP 的诊断代理,但不能代替真实 INP。
十、迁移前的检查清单
迁移前不要只比较压缩后 JavaScript 文件的 KB 数,还应逐项确认:
- 哪些页面真的需要复杂客户端状态;
- 哪些组件可以保留服务器 HTML,只增强按钮和表单;
- 是否需要路由、表单校验、国际化、无障碍和 SSR;
- Vue 插件、指令、mixins 和第三方组件的替代方案;
- Alpine 3 插件和构建方式是否兼容;
- 模态框、菜单、焦点管理和键盘操作是否通过无障碍测试;
- 事件通信是否可追踪,状态是否有明确所有者;
- 迁移后首屏脚本、长任务、LCP、INP、CLS 和错误率是否真的改善;
- 是否有可回滚的灰度发布和真实用户监控。
十一、写在最后
Vue 并不是“错误答案”。原文项目发现:对一个由服务器模板生成、交互点分散、没有 SPA 路由的电商站点,把整个页面交给 Vue 根实例会带来不必要的初始化成本;Alpine.js 更贴合它的局部增强需求。
这条经验可以总结为:
先观察页面的渲染边界、交互复杂度和数据流,再选择框架。不要为了追逐某个排行榜分数,机械地把 Vue 换成 Alpine;也不要为了使用熟悉的框架,把一整页静态 HTML 都变成客户端应用。