当代码不完全属于你:一次 Shopify 性能优化实战

75 阅读13分钟

作者:志发|灏仟亿大前端技术团队

常规的性能优化,默认前提往往是:代码在自己手里,想怎么改就怎么改。压缩资源、合并请求、接入 CDN、懒加载、代码分割、SSR,这些手段都没有问题。

但 Shopify 不完全是这样。主题代码可以直接修改,页面里相当一部分 JS、CSS 和 DOM 却来自第三方 App:删不掉,也改不了源码。性能优化不能只靠“正面对代码动刀”,而是要在受约束的环境里做治理:拦截、延后释放、缩小作用域、设置白名单,以及必要时重放事件。

案例分为两段:店铺 A 用来定位问题、建立判断;店铺 B 用来把这套判断落地、验证并沉淀为可复用的 skill。

先对齐几个 Shopify 术语

  • PDP:产品详情页,即顾客点开某件商品后看到的页面。
  • 变体(Variant) :同一商品的颜色、尺码等选项;切换变体时,价格和图片会联动。
  • Bundle:捆绑销售,通常由第三方 App 在 PDP 上渲染。
  • App / 插件(App Embed) :后台安装的第三方功能,会向页面注入自己的 JS、CSS 和 DOM。
  • 主题(Theme) :可直接修改的前端模板。
  • LCP、FCP、TBT、SI:Lighthouse 中会反复出现的性能指标。

两个店铺,两段推进

故事从店铺 A 的 PDP 开始:最初只有 26 分,首轮优化后升到 57 分,但深度诊断发现,TBT、主线程和插件才是主要问题。

店铺 B 则把这套判断落实到图片、脚本和插件治理上,首页 Performance 从 45 提升到 83。

从一个 26 分的 PDP 开始

2026 年 5 月 6 日,店铺 A 的 PDP PageSpeed 移动端报告只有 26 分:FCP 为 5.4 秒,LCP 为 11.4 秒,TBT 为 3780 毫秒,SI 为 15.1 秒。

问题不是“要不要优化”,而是“先从哪里下手”。如果一开始就无差别地改脚本,很可能分数还没提升,变体、加购或支付链路先被改坏了。

第一轮:26 分到了 57 分,但核心问题还在

首轮优化后,PDP 的 Performance 从 26 提升到 57。LCP 从 11.4 秒降至 2.6 秒,FCP 从 5.4 秒降至 2.6 秒。

首屏确实变快了,但 TBT 仍有 1710 毫秒。按原始材料采用的 Lighthouse v10 口径,TBT 的权重为 30%,LCP 与 CLS 各为 25%,FCP 与 SI 各为 10%。权重最大的指标没有降到位,总分就会被卡住。

不能只看 LCP。LCP 降下来,并不意味着性能问题已经解决。

第一轮具体做了什么

首轮改动主要集中在首屏外围:

  1. 关键图优先,其他图片懒加载:第一个商品图使用 eager 与 fetchpriority=high,缩略图及其他图片使用 loading=lazy。
  2. 关键 CSS 内联,其他 CSS 延后:首屏必需样式放入 <style>,其余样式拆出并按需加载。
  3. 脚本延迟,并在首次交互时释放:营销像素与统计脚本进入队列,首次交互或超时时再释放,避免阻塞首屏。
  4. PhotoSwipe 按需加载:只在用户打开灯箱时再拉取并初始化。

这些动作让 FCP、LCP 与 SI 都变快了,却没有触及主线程与插件,所以 TBT 仍然偏高。

深度诊断:主线程与插件才是主因

继续分析后,店铺 A 的主线程工作量为 10.3 秒,第三方请求占 45.5%。这意味着大量网络请求和 CPU 时间被第三方插件占用。

诊断不只是列出“哪些插件慢”,更重要的是先定处理边界:

  • 首屏必需的功能:不动。
  • 非关键功能:在交互时或超时后释放。
  • PDP、购物车、支付、Bundle 相关逻辑:默认进入白名单。

如果为了压低 TBT,把加购脚本也延后,首个加购可能会失效。分数下降并不等于优化成功。

为什么插件最难处理

插件承载着真实业务。Rapi、Kaching 涉及 Bundle 的加购和价格逻辑,CartSee 负责购物车抽屉,Loox 提供评论,Size Chart 提供尺码表,Web Pixels 负责归因。

因此,插件优化的边界不是“删除”,而是“减法式治理”:缩小作用域、延后非首屏内容、白名单保护关键路径,并逐项回归验证。

这次优化要回答的三个工程问题

  1. 谁才是真正的 LCP? 用 audit 定位,不凭感觉猜首图。
  2. 哪些 JS 可以晚到? 按页面、交互和业务风险分级。
  3. 怎么证明改动没有破坏 转化 链路? 变体、加购、折扣等流程必须回归验证。

让真正的首屏元素先到

LCP 元素不是固定的。页面加载时,浏览器可能先绘制一个较小元素,之后再绘制更大的首屏图,最终把 LCP 更新为后者。因此,先用 PerformanceObserver 确认最后一个候选元素,再决定要预加载哪张图。

const observer = new PerformanceObserver((entryList) => {
  for (const entry of entryList.getEntries()) {
    console.log('LCP 候选:', entry.startTime + 'ms →', entry.element);
  }
});

// 最后一个元素才是真正的 LCP
observer.observe({ type: 'largest-contentful-paint', buffered: true });

确认元素后,再针对首图设置 preload,而不是凭感觉把所有轮播图都提前加载。

{%- if first_desktop_img != blank -%}
  <link rel="preload" as="image"
    href="{{ first_desktop_img | image_url: width: 1600 }}"
    imagesizes="100vw"
    media="(min-width:750px)"
    fetchpriority="high">
{%- endif -%}
{# 移动端同样只 preload 第一张,其余 lazy #}

验证时需要确认三件事:LCP audit 与 observer 指向同一张图;只有首个图片使用 eager/high;轮播中的后续图片仍然保持 lazy。

脚本按业务风险分级,而不是一刀切 defer

营销、热图、广告和评论等非关键脚本可以进入延后队列;购物车、支付、Bundle、变体等转化链路必须保留在白名单中。队列的释放时机是首次交互,并用 45 秒超时兜底。

var blockedSrcPatterns = [
  // 营销 / 热图 / 广告 / 评论等非关键脚本
  /clarity.ms/, /googletagmanager.com/gtag/js/,
  /connect.facebook.net/, /cdn.taboola.com/,
  /loox.io/, /luckyorange.com/, /sc-static.net/
];

// 拦截动态注入,先进队列不阻塞首屏
Element.prototype.appendChild = function (node) {
  if (!released && isDeferredAnalyticsScript(node)) {
    queue.push(function () { originalAppendChild.call(this, node); });
    return node;
  }
  return originalAppendChild.call(this, node);
};

// 首次交互立即释放;45s 超时兜底
['pointerdown', 'touchstart', 'keydown', 'scroll'].forEach(function (ev) {
  document.addEventListener(ev, releaseDeferredScripts, { once: true, capture: true });
});
setTimeout(releaseDeferredScripts, 45000);

// 白名单:cart / payment / bundle / variant 不进队列

“释放”不是删除脚本。首次交互时立即放行,超时作为兜底;PDP、购物车、支付和 Bundle 则明确作为例外。

只治理插件图片,不碰插件业务

插件会动态插入图片和 DOM。这里的原则是只处理图片资源:限定在 Rapi、Kaching 等容器内,补充合适的宽度、loading=lazy、decoding=async 和低优先级;动态节点只监听 src、srcset 的变化,不碰价格、data-*、事件和加购逻辑。

var appImageSelector = [
  '.rapi-bundles__bar-main .offer-image-preview img',
  '.rapi-gift-product-inline .offer-image-preview img',
  '[class*="kaching"] img'
].join(',');

function normalizeAppImage(image) {
  var w = getAppImageTargetWidth(image); // 96 / 160 / 180
  image.loading = 'lazy';
  image.decoding = 'async';
  image.setAttribute('sizes', w + 'px');
  if ('fetchPriority' in image) image.fetchPriority = 'low';
  image.setAttribute('src', sizedShopifyImageUrl(image.src, w));
}

// 动态节点:只监听 src / srcset,不碰 data-* 与事件
new MutationObserver(handler).observe(document, {
  childList: true,
  subtree: true,
  attributeFilter: ['src', 'srcset']
});

先缩小作用域,再监听动态节点,避免把治理图片的改动扩散到插件的价格和加购逻辑上。

主题功能按需加载:有触发、只加载一次、保留兜底

搜索、购物车通知、弹窗、地区切换和动画等功能,可以在功能存在且真正需要时加载。触发点包括搜索 focus、加购动作和进入动画视窗;可见导航、变体、加购、支付以及首屏必要 CSS 则不应延后。

function loadScriptOnce(key, src) {
  if (lazyScriptState[key]) return; // 只加载一次
  lazyScriptState[key] = true;
  var s = document.createElement('script');
  s.src = src;
  s.defer = true;
  document.head.appendChild(s);
}

function loadStyleOnce(key, href) { /* 同样去重 */ }

// 动画脚本:空闲时再加载,不阻塞首屏
requestIdleCallback(function () {
  loadScriptOnce('animations', "{{ 'animations.js' | asset_url }}");
}, { timeout: 4000 });

// 购物车通知 / 弹窗 / 地区切换:首次交互时加载

PDP 变体首图:精准补优先级,不重写媒体组件

PDP 的 LCP 是选中变体的首图。这里的做法是预加载选中变体首图,让第一个真实 LCP 图使用 eager 与 fetchpriority=high;zoom、modal hooks、media id、ARIA、变体切换和原有图片 snippet 全部保留。

<link rel="preload" as="image"
  href="{{ lcp_media | image_url: width: 713 }}"
  imagesrcset="{{ product_lcp_srcset }}"
  imagesizes="calc(100vw - 4rem)"
  media="(max-width: 749px)" fetchpriority="high">

{# 只有首个真实 LCP 图 eager,其余保持 lazy #}
{%- if lazy_load == false -%}
  {{ product_media_image | replace: '<img ', '<img loading="eager" fetchpriority="high" ' }}
{%- endif -%}

{# zoom / modal hooks、media id、ARIA、variant 切换全部保留 #}

关键取舍是:为了一个审计项补足优先级,不要重写 PDP 的媒体逻辑。

结果:店铺 B 首页从 45 到 83

图片、脚本和插件治理落地后,店铺 B 首页 Performance 从 45 提升到 83,TBT 从 510 毫秒降到 10 毫秒。最终汇报采用同一生产 URL 的统一复测口径。

上线前验证的不是分数,而是真实功能

Lighthouse 是线索,不是目标。上线前需要实际验证:

  • 首屏 banner 在桌面和移动端不会重复下载。
  • 搜索首次 focus、菜单和弹窗可以正常工作。
  • 变体切换、加购和购物车抽屉正常。
  • Bundle、折扣和地区切换正常。
  • 分析脚本最终能够释放。

同样有一些“优化”不应做:不要直接删除 content_for_header 或 App Embed;不要给未知脚本统一加 async/defer;不要改共享 snippet 的默认行为;不要用假占位换取漂亮分数;更不要牺牲归因、优惠或支付。

一次踩坑:延后统计脚本导致加购事件漏报

为了压低 TBT,曾把统计、分析脚本也放进延后队列,结果后台统计异常,加购事件漏报,影响广告投放归因和运营判断。

原因是统计脚本依赖加购事件监听。延后加载改变了执行顺序,事件发生时监听器还没有注册,于是事件被漏采。

回滚方式是改为“交互时尽快释放 + 事件重放”:先缓存事件,脚本就位后再补发。这个复盘留下的边界很明确:统计和归因脚本与转化事件强相关,延后必须配事件重放兜底,先保数据,再谈性能。

把判断沉淀成 skill

优化结束后,这套经验被沉淀为 shopify-performance-optimization skill,包含四部分:诊断模板、风险分级、实现片段和回归清单。

原始材料还记录了它在另一家店铺上的一次验证:同一套做法只改动 6 个文件,首页 Performance 从 61 到 82,LCP 从 7.7 秒到 3.7 秒,TBT 从 120 毫秒到 0 毫秒,总传输从 21.2 MB 降至 1.74 MB。

它的流程仍然是前面已经实践过的内容:统一口径,定位根因,做最小改动,复测并回归。用于测速的标准命令如下:

npm exec -- lighthouse 'https://xxx/?pb=0' --only-categories=performance --output=json

其中 ?pb=0 用于关闭 Shopify 预览栏,避免干扰测速。

先统一测试口径,否则数字没有讨论价值

性能数据要固定页面、固定环境,并重复取样;首页与 PDP 不能混用模板预览页;移动端模拟需要保持相同网络和地区;至少取样 3 次,记录中位数与波动;实验室数据(Lab)和真实用户数据(RUM)也要分开记录。

验收以 PageSpeed 为准。Lighthouse 适合开发时做本地诊断,而业务侧使用的 PageSpeed Insights 会模拟低速 4G 网络节流,分数通常会比本地更低。对外报数时,应以同口径前后的变化为准,并标注测试批次。

从低分报告到根因,不只看一个 Score

排查过程可以按以下顺序推进:

  1. 跑 Lighthouse,先定位异常指标。
  2. 看诊断项,检查 JS、图片和第三方资源。
  3. 打开 Performance,分析长任务和调用栈。
  4. 映射到主题、App 与像素等具体业务对象。
  5. 小步改动,复测并做业务回归。

反面的做法是看到 “Eliminate render-blocking resources” 就给所有脚本加 defer。正确路径是先定位异常指标,再顺着诊断项找长任务,映射到具体 App 或脚本,最后小步改动、逐项复测。

主线程的 10.3 秒都在做什么

原始诊断中,主线程 10.3 秒里,JS 执行占 4.2 秒;其余为解析、样式、布局、绘制和 GC,共计 6.1 秒。

  • 主题脚本:如 vendor.js、theme-global.js,首屏必需,保留。
  • App 初始化:如 Rapi、CartSee、Size Chart、Loox,必需但可以治理。
  • 像素 与营销:Web Pixels、热图和广告脚本,可以延后进入队列。

目标不是责怪某个 App,而是找到“首屏必须执行”的最小集合。

别忽略 703 KB 的内联脚本

另一个根因是 703 KB 的内联脚本,占 HTML 文档 1.08 MB 的 64.8%。内联 JS 没有额外网络请求,不代表它不消耗下载、解析和编译时间。

<!-- 之前:大对象全量内联进 HTML(703KB,阻塞解析) -->
<script>
  window.__APP_STATE__ = {{ product | json }}; // 全量
</script>

<!-- 之后:只留首屏必需字段,其余延后拉取 -->
<script>
  window.__APP_STATE__ = { id, title, price, media }; // 精简
</script>
<!-- 变体 / 推荐 / 配置等非首屏数据:fetch 延后获取 -->

处理原则是删去冗余状态,延后非首屏状态,而不是因为它没有独立请求就忽略它。

同口径下的首页对照

在同口径复测中,店铺 B 的首页从 45 分提升到 83 分,TBT 从 510 毫秒降到 10 毫秒,Speed Index 从 8.9 秒降到 1.0 秒;LCP 以同批次报告记录为准。

每一个延迟,都要有业务理由

脚本加载策略可以按业务边界拆开:

  • 首屏主题功能:立即加载;验收时检查首屏可用性、报错和 CLS。
  • 搜索、弹窗、动画:首次交互时加载;交互后仍要立即可用。
  • 统计、热图、广告:交互时加载,并保留超时兜底;保证归因最终发送。
  • 支付、加购、Bundle:进入白名单,不延后;转化链路优先于性能分。

将清单写成代码前,需要由业务或运营确认每项功能的降级时机与可观测信号。

下一步:从一次优化走向持续治理

原始材料将这部分标为下一步,而非已完成工作。持续治理包含三个方向:

  • 性能预算:新增 App 前记录请求数、JS 体积和图片数;首页 JS 不超过 500 KB、请求不超过 80,超出阈值必须评审。
  • 自动复测:首页与 PDP 在发布前和大促前必跑,保存趋势并看中位数;指标回退时报警并定位 diff。
  • 业务回归:加购、变体、折扣、支付和归因必测;自动化冒烟结合人工抽查,异常时一键回滚到上一版。

这套闭环可以概括为:性能预算 → 自动复测 → 业务回归 → 回滚。完成一次优化不是终点,下一次上线不再把 TBT 拉回去,才是持续治理的目标。

总结

这次优化最终沉淀为七步:

  1. 统一口径。
  2. 定位根因,而不只看分数。
  3. 图片优先:关键图 eager + preload。
  4. 脚本分级:关键脚本立即加载,非关键脚本按时机释放。
  5. 插件治理:缩小作用域,只处理图片资源。
  6. 业务回归:转化链路每次改动都要验证。
  7. 沉淀复用:将判断、实现片段和清单写进 skill。

顺序同样重要:先定尺子,再找真凶;小步改,逐项回归;最后才能沉淀复用。分数是结果,用户体验与业务安全才是目标。