站外页面是用户第一次接触大众点评的入口——从微信分享、搜索引擎到短视频跳转,每一毫秒的加载延迟都在直接流失转化。增长团队面对的现实很骨感:旧 M 站基于传统 SSR + 水合架构,首屏可交互时间被框架自身的 hydration 拖累,维护成本也随着业务膨胀居高不下。引入 Qwik.js 重构后,团队用"可恢复性"替代了水合,配合全链路工程优化,各核心页面的性能指标拿到了肉眼可见的提升。下面拆解这次重构的选型逻辑、原理和落地细节。
传统水合的瓶颈到底在哪
主流 SSR 框架(Next.js、Nuxt 等)的工作流是:
- 服务端渲染 HTML,用户快速看到静态内容。
- 浏览器下载框架 JS + 组件逻辑。
- 框架在客户端重新执行组件树,把事件监听和状态逐一"贴回" DOM 节点——这就是水合(hydration)。
问题出在第 2、3 步。水合要求客户端必须下载并执行与服务端渲染等量的组件逻辑,才能让页面"活起来"。对于站外场景——用户网络环境不可控、设备性能跨度大——这层开销直接拉长了 FCP 到 TTI 的间隔:页面看起来已经渲染完了,按钮却点不动。
大众点评旧 M 站的症状正是如此:SSR 输出的 HTML 很快可见,但水合脚本体积大、执行耗时久,用户在等待"页面苏醒"的过程中频繁跳出。
Qwik 的可恢复性:不重新执行,而是续接
Qwik 的核心思路是——服务端渲染时就把组件状态和事件监听序列化进 HTML,客户端不需要重新执行组件逻辑,只需要在用户触发交互时按需加载对应代码片段。
具体机制:
- 事件监听序列化:服务端渲染时,Qwik 把每个交互元素的事件处理器引用(如
onClick: "submitForm")直接写进 HTML 属性,而不是在客户端重新绑定。 - 懒加载到函数级别:用户点击按钮时,Qwik 才动态加载该按钮对应的处理函数,其余代码永远不会下载。
- 状态恢复而非重建:组件状态被序列化到 HTML 的
<script>标签中,客户端直接反序列化恢复,不走组件重建流程。
这意味着首屏 HTML 到达浏览器后,页面就已经是可交互的——不需要等待任何框架 JS 下载和执行。
技术选型:为什么是 Qwik 而不是别的
团队评估了几个方向:
| 方案 | 首屏交互延迟 | 改造幅度 | 生态成熟度 |
|---|---|---|---|
| Next.js SSR + selective hydration | 中等(仍需水合) | 低 | 高 |
| Islands Architecture(Astro) | 较低(局部水合) | 中 | 中 |
| Qwik resumability | 最低(零水合) | 高 | 较低 |
站外场景的核心诉求是首屏可交互速度极致压缩,Qwik 的零水合特性在这里收益最大。生态成熟度偏低是真实风险,但 M 站页面类型相对集中(详情页、列表页、活动页),复杂度可控,团队决定承受这个代价换取性能上限。
落地细节:从组件到全链路
组件写法与事件懒加载
Qwik 组件使用 component$ 声明,$ 后缀是 Qwik 的懒加载边界标记——被 $ 包裹的函数会被自动提取为独立可懒加载的 chunk:
import { component$, useSignal } from '@builder.io/qwik';
// component$ 标记整个组件为懒加载单元
export const ShareButton = component$(({ url }: { url: string }) => {
const loading = useSignal(false);
return (
<button
// $ 后缀:此处理函数会被提取为独立 chunk,仅在用户点击时加载
onClick$={async () => {
loading.value = true;
await fetch(`/api/share?url=${encodeURIComponent(url)}`);
loading.value = false;
}}
class="share-btn"
>
{loading.value ? '分享中...' : '分享给朋友'}
</button>
);
});
关键点:onClick$ 里的异步函数不会出现在首屏 JS 中。用户不点这个按钮,这段代码永远不会被请求。对于站外页面常见的"分享、领券、跳转"等交互,每个按钮的处理逻辑都变成了按需加载。
状态序列化与恢复
import { component$, useStore } from '@builder.io/qwik';
export const CouponCard = component$((props: { couponId: string }) => {
// useStore 创建的状态会被自动序列化到 HTML 中
const state = useStore({
claimed: false,
countdown: 3600,
});
return (
<div class="coupon-card">
<span>{state.claimed ? '已领取' : `剩余 ${state.countdown}s`}</span>
<button
onClick$={() => {
state.claimed = true;
}}
>
立即领取
</button>
</div>
);
});
useStore 的状态在 SSR 时被序列化进 HTML,客户端直接从 HTML 反序列化恢复——不需要重新执行组件来重建状态。这对有初始数据的站外页面(如从 URL 参数带入的店铺 ID、活动信息)尤其重要:数据一次渲染到位,客户端零重复计算。
全链路优化配合
单换框架不够,团队做了配套优化:
- 资源预加载:对关键图片和下一页数据使用
<link rel="prefetch">,在用户浏览当前页时静默拉取。 - 边缘缓存策略:SSR HTML 按 URL 模式 + 参数组合做边缘缓存,热门页面命中率高,回源压力小。
- Qwik City 路由优化:利用 Qwik City 的路由级代码分割,每个路由只加载自身依赖。
用 curl 检查边缘缓存命中和首屏 HTML 体积:
# 检查边缘缓存命中状态
curl -sI "https://m.dianping.com/shop/12345" \
| grep -i "x-cache"
# 查看首屏 HTML 大小(Qwik SSR 输出,含序列化状态)
curl -s "https://m.dianping.com/shop/12345" \
| wc -c
# 对比:首屏 JS chunk 数量(Qwik 懒加载边界产生的细粒度 chunk)
curl -s "https://m.dianping.com/shop/12345" \
| grep -o 'q:[^"]*\.js' | sort -u | wc -l
重构后,首屏 HTML 体积略有增加(因为序列化状态写进了 HTML),但首屏 JS 总量大幅下降——用户真正需要下载的代码从"整个组件树"缩减到"零"(直到触发交互才按需加载)。
收益与代价
性能收益
- FCP → TTI 间隔显著缩短:页面可见即可交互,不再有水合等待期。
- 首屏 JS 体积下降:核心页面首屏 JS 从数百 KB 降至接近零,交互代码按需加载。
- Lighthouse 分数提升:各核心页面的 Performance 分数均有明显提升。
需要承受的代价
- HTML 体积膨胀:序列化状态和事件引用写进 HTML,首屏 HTML 比传统 SSR 更大。对于状态复杂的页面需要评估膨胀比例。
- 调试体验变化:懒加载边界让代码在运行时分散在不同 chunk 中,排查问题时需要理解 Qwik 的加载时序。
- 生态限制:Qwik 社区规模小于 React/Vue,第三方组件库适配需要自行封装,团队维护了一层桥接层来复用已有的 React 业务组件。
什么时候值得用
Qwik 不是通用替代方案,它在以下场景收益最大:
- 站外/流量入口页面:用户跳出率高、网络不可控,首屏交互速度是生死指标。
- 交互密度低的页面:大量展示 + 少量按钮交互,懒加载收益远大于序列化开销。
- SEO + 性能双要求:SSR 输出完整 HTML 满足爬虫,可恢复性满足用户体验。
对于交互密集的管理后台、状态复杂的编辑器类应用,传统水合框架的生态和调试体验仍然更合适。
上手检查清单
如果考虑在站外场景引入 Qwik,可以按这个清单逐项评估:
- 页面交互模式盘点:列出所有首屏交互点,计算懒加载可削减的 JS 体积 vs 序列化增加的 HTML 体积。
- 状态复杂度评估:
useStore序列化的数据量是否可控?超过 10KB 的初始状态需要考虑分片或延迟加载。 - 现有组件复用方案:规划 React/Vue 组件的桥接策略,确认关键 UI 组件有 Qwik 封装或可替代方案。
- 边缘缓存设计:SSR HTML 的缓存 key 设计要覆盖 URL 参数组合,避免状态错乱。
- 监控埋点:Qwik 的交互加载是异步的,点击到响应的延迟需要单独监控,不能只看传统 FCP/TTI。
大众点评 M 站这次重构验证了一个判断:在站外场景,可恢复性比水合更贴合性能极限。代价真实存在,但选对场景后收益足够覆盖。