你有一个不错的菜单。客户喜欢它。它能正常工作。但每次你用 PageSpeed Insights 测试你的店铺时,Google 都会标记一个特定文件:你的菜单脚本。标签说”渲染阻塞 JavaScript”,文件大小 120KB,它给你的移动端 LCP 增加了 600 毫秒。你知道需要修复这个问题,但上次有人建议使用 defer,菜单就坏了。一半的下拉菜单停止工作,移动切换按钮没有反应,你在一小时内就回滚了。
这是一个常见问题。渲染阻塞脚本是 Shopify 店铺中影响最大的性能瓶颈之一,菜单是经常出现的罪魁祸首。但延迟加载 JavaScript 不仅仅是在脚本标签中添加一个属性。如果做错了,菜单就无法工作。如果做对了,你可以在不改动菜单逻辑的情况下,从移动端 LCP 中剪掉半秒钟。
本文解释了渲染阻塞的真正含义,为什么 async 和 defer 表现不同,以及如何加载菜单脚本而不破坏功能。
- 渲染阻塞脚本会暂停所有页面渲染,直到脚本下载、解析和执行完成。
defer并行下载脚本,但将执行延迟到 HTML 解析完成。async立即下载和执行,这可能会破坏依赖 DOM 顺序的菜单。type="module"默认延迟执行,并支持现代 JavaScript 功能。- 延迟加载后菜单破坏的原因通常是初始化时序问题,而不是 defer 属性本身。
渲染阻塞的真正含义
当浏览器遇到没有加载属性的 <script> 标签时,它会停止一切。它暂停 HTML 解析,下载脚本,解析脚本,执行脚本,然后才继续构建页面。这称为同步或渲染阻塞行为。
这种行为的逻辑很古老但合理:JavaScript 可以用 document.write() 修改 DOM,所以浏览器假设每个脚本都可能改变页面的结构,因此必须在继续之前运行。
这就是问题所在。你的菜单脚本不使用 document.write()。它不需要在页面其余部分解析之前运行。但浏览器不知道这一点,所以它还是会阻塞。
根据 Chrome 自己关于优化最大内容绘制的文档,消除渲染阻塞 JavaScript 是你能做的影响最大的改进之一。当文档顶部的脚本阻塞 400 毫秒时,DOM 中它下方的每个元素——你的英雄图片、你的标题、你的产品网格——都要多等 400 毫秒才能绘制。
三种加载策略:默认、Defer 和 Async
浏览器有三种方式可以加载脚本。
默认(同步)
<script src="menu.js"></script>
浏览器停止解析 HTML,下载 menu.js,解析它,执行它,然后恢复。这会在整个持续期间阻塞渲染。
Defer
<script src="menu.js" defer></script>
浏览器并行下载 menu.js 和 HTML 解析,但直到整个 HTML 文档解析完成才执行它。多个延迟脚本按它们在 HTML 中出现的顺序执行。
Async
<script src="menu.js" async></script>
浏览器并行下载 menu.js 和 HTML 解析,一旦下载完成就立即执行它,即使 HTML 解析未完成。如果多个脚本使用 async,执行顺序是不可预测的。
这是一个总结行为的表格:
| 属性 | 下载 | 执行 | 阻塞 HTML 解析 | 执行顺序 |
|---|---|---|---|---|
| 无(默认) | 阻塞解析 | 立即 | 是 | 顺序执行 |
defer |
并行 | HTML 解析后 | 否 | 顺序执行 |
async |
并行 | 就绪后立即 | 是(执行期间) | 不可预测 |
对于大多数导航菜单,defer 是正确的选择。它保持脚本顺序,当一个脚本依赖另一个时很重要(例如,依赖工具库的菜单脚本)。它保证 DOM 在脚本运行前已准备好,这对任何查询元素的代码至关重要。
为什么 Defer 会破坏某些菜单(以及如何修复)
你在菜单脚本标签中添加 defer,重新加载页面,菜单无法工作。下拉菜单无法打开。移动切换不起作用。你移除 defer,一切都正常了。发生了什么?
问题几乎总是初始化时序。这是三个最常见的原因。
问题 1:脚本在主题 JavaScript 准备好前运行
许多 Shopify 主题有其他脚本所依赖的全局 JavaScript 对象或初始化函数。如果你的菜单脚本在该对象创建前运行,它就会失败。
示例:
// theme.js(首先加载)
window.Theme = { utils: { ... } };
// menu.js(第二个加载,期望 Theme 存在)
Theme.utils.initDropdown();
如果 menu.js 延迟但 theme.js 没有,menu.js 可能会在 theme.js 完成执行前运行,Theme 将是 undefined。
修复方法: 延迟两个脚本,或确保 menu.js 在初始化前检查依赖。
if (typeof Theme !== 'undefined') {
Theme.utils.initDropdown();
}
问题 2:内联脚本在延迟脚本前运行
内联脚本(直接在 HTML 中编写的 JavaScript,而不是从外部文件加载)按遇到的顺序在文档中执行。延迟脚本在整个 HTML 解析后执行,这意味着在所有内联脚本之后。
如果你有内联脚本尝试初始化菜单,而菜单库延迟加载,初始化将失败。
修复方法: 将初始化逻辑移到延迟脚本中,或用 DOMContentLoaded 监听器包装内联初始化。
document.addEventListener('DOMContentLoaded', function() {
if (typeof MenuApp !== 'undefined') {
MenuApp.init();
}
});
问题 3:多个延迟脚本的竞态条件
如果你的菜单依赖工具库,两者都延迟加载,它们应该按在 HTML 中出现的顺序执行。但如果工具库异步加载(使用 async 而不是 defer),执行顺序变得不可预测。
修复方法: 对所有相互依赖的脚本使用 defer,而不是 async。如果必须使用 async,添加显式依赖检查或使用动态导入。
Type=”module”:现代替代方案
现代 JavaScript 支持 ES6 模块,它们有内置的延迟加载。
<script type="module" src="menu.js"></script>
带有 type="module" 的脚本自动延迟。它们并行下载,在 HTML 解析完成后执行,就像 defer 一样。它们也支持 import 和 export,这使依赖管理显式。
如果你的菜单脚本是作为模块编写的,你可以直接导入依赖:
import { initDropdown } from './dropdown.js';
initDropdown();
这消除了传统 defer 导致破坏的时序问题,因为浏览器确切知道哪些脚本依赖哪些。
缺点:旧浏览器(Internet Explorer、非常旧的 Safari 版本)不支持模块。但根据 Can I Use,截至 2025 年,全球超过 95% 的浏览器流量支持 ES6 模块。对于大多数 Shopify 店铺,这是安全的。
Shopify 特定考虑
Shopify 主题根据主题架构以不同的方式加载脚本。以下是延迟加载在最常见模式中的工作方式。
Theme.liquid 脚本标签
如果你的菜单脚本像这样在 theme.liquid 中加载:
{{ 'menu.js' | asset_url | script_tag }}
script_tag 过滤器默认不添加 defer。你需要用手动 <script> 标签替换它:
<script src="{{ 'menu.js' | asset_url }}" defer></script>
应用嵌入块
如果你的菜单作为应用嵌入安装(第三方菜单应用很常见),应用控制其脚本如何加载。你无法自己添加 defer。你需要与应用开发者联系或切换到已经异步加载脚本的菜单应用。
像 Navi+ 这样的工具默认用 defer 加载它们的 JavaScript,这是它们在 LCP 测试中性能比传统菜单应用更好的原因之一。
区块渲染和延迟脚本
Shopify 2.0 主题动态加载区块。如果区块包含延迟脚本,并且区块在初始加载后添加到页面(例如,通过 AJAX 进行快速查看或无限滚动),脚本可能不会按预期执行。
修复方法: 只对初始页面加载使用 DOMContentLoaded。对于动态添加的区块,使用自定义事件或 mutation observer 手动触发初始化。
如何测试 Defer 是否有效
在将延迟脚本加载部署到你的在线店铺之前,彻底测试它。这是一个检查清单。
- 桌面 Chrome: 打开你的店铺,打开 DevTools,转到”网络”标签,重新加载,验证菜单脚本与其他资源并行下载,不阻塞文档。
- 移动模拟: 在 DevTools 中,切换到移动仿真(iPhone 或 Android),将网络限流到”快速 3G”,然后重新加载。菜单应该仍然正确初始化。
- 跨浏览器: 在 Safari、Firefox 和 Edge 中测试。模块脚本在 Safari 中的行为略有不同。
- 真实设备: 在真实移动连接下在实际手机上打开店铺。仿真很有用,但真实世界的延迟暴露了桌面测试错过的时序错误。
- 功能检查: 打开每个下拉菜单,测试移动菜单切换,点击每个链接,验证没有任何破坏。
推送前在启用 defer 前后对同一页面运行 PageSpeed Insights 测试。你应该看到 LCP 有明显下降,渲染阻塞资源也减少。如果 LCP 没有改进,脚本可能不是瓶颈,或者可能还有其他要解决的阻塞资源。
何时 Async 有意义(何时没有)
async 适用于完全独立的脚本,不依赖 DOM 顺序或其他脚本。示例包括分析追踪器、聊天小部件和某些广告脚本。
对于导航菜单,async 是有风险的。菜单需要 DOM 准备好,它通常依赖主题工具,它通常需要在客户与页面交互前运行。如果脚本在 HTML 仍在解析时执行,它可能尝试查询不存在的元素。
如果你在考虑对菜单脚本使用 async,脚本必须:
- 不依赖任何其他脚本
- 不查询 DOM 元素(或用
DOMContentLoaded检查包装所有查询) - 足够小,其执行时间不阻塞解析
大多数菜单脚本不符合这些标准。除非你有特定原因使用 async,否则坚持使用 defer 或 type="module"。
良好的菜单脚本加载看起来像什么
Shopify 店铺上适当优化的菜单脚本看起来像这样:
<script src="{{ 'menu.js' | asset_url }}" defer></script>
或者,如果使用模块:
<script type="module" src="{{ 'menu.js' | asset_url }}"></script>
脚本与页面其余部分并行下载,在 DOM 准备好后执行,不阻塞英雄图片或任何其他 LCP 元素绘制。
在 Chrome DevTools 性能时间线中,你应该看到英雄图片在菜单脚本执行前绘制。如果菜单脚本首先运行,它仍在阻塞。
大多数店铺仅通过从同步切换到延迟脚本加载,就可以从移动端 LCP 中切掉 300 到 600 毫秒。菜单仍然工作。客户不会注意到区别。但 Google 会,更重要的是,浏览器会。页面变得更快可见,这就是要点。
本文是关于更大指南的一部分 菜单与 LCP:导航如何阻塞你最大内容绘制。