script标签放错位置会使TTI增加300–800ms;因未加defer/async的同步脚本会阻塞HTML解析与渲染,导致DOM不可用、交互延迟,尤其影响SSR水合和移动端弱网体验。

script 标签放错位置,TTI(可交互时间)会多出 300–800ms —— 这不是理论值,是真实 Lighthouse 和 Web Vitals 中位数差距。
为什么 <script> 放在 <head> 里会卡住渲染
浏览器解析 HTML 是流式进行的。遇到没加 defer 或 async 的 <script src="app.js">,会立刻暂停 DOM 构建,等 JS 下载 → 解析 → 执行完才继续。哪怕脚本只写 console.log('hi'),它也阻塞整个渲染流水线。
- 常见错误现象:
document.getElementById()返回null、首屏文字已渲染但按钮点击无响应、DOMContentLoaded时间远晚于first-contentful-paint - 根本原因:同步脚本强制主线程等待,无法并发处理 HTML 解析和 JS 执行
- 移动端弱网下尤其明显:用户看到内容却无法交互,Lighthouse 报 “Reduce JavaScript execution time”
defer 和 async 对 TTI 的实际影响差异
两者都让脚本不阻塞 HTML 解析,但执行时机不同,直接影响 DOM 可用性和水合逻辑是否出错。
-
defer:DOM 解析完再按声明顺序执行,适合主应用入口(如React.render()、Vue.createApp().mount()),能安全操作 DOM -
async:下载完就执行,顺序不确定;适合无依赖、不操作 DOM 的独立脚本(如统计埋点),但若提前执行,document.querySelector()可能返回null,触发降级或报错,延长水合时间 - 多个
defer脚本保持顺序,利于依赖管理;async无法保证顺序,慎用于模块链(如utils.js→main.js)
SSR 水合脚本必须用 defer,不能靠位置“凑合”
服务端渲染后,客户端 JS 需要“接管”已存在的 DOM 节点。如果水合脚本没加 defer,又放在 <head>,它可能在 DOM 尚未就绪时执行,导致 hydrateRoot 失败或 fallback 到客户端重绘。
立即学习“前端免费学习笔记(深入)”;
- 错误做法:把水合脚本扔到
</body>前,以为“位置靠后就安全”——但若没加defer,它仍可能在 DOM 构建中途被插入并执行 - 正确做法:明确加
defer,且确保它在所有必要 DOM 节点之后声明(比如在<body>底部,但属性比位置更关键) - 内联小初始化脚本(如主题切换)可放
<head>,但要用type="module"(自动defer),避免同步阻塞
别再迷信“放到底部就万事大吉”
把 <script> 塞进 </body> 前,只是规避了最粗暴的阻塞,但没解决加载策略问题。现代框架应用中,JS 包体积大、依赖复杂,仅靠位置无法保障 TTI。
- 第三方脚本(如 GA、Sentry)必须加
async,否则它们常含同步埋点逻辑,拖慢主线程 - 主应用包建议用
defer,而非默认同步;构建工具(如 Vite、Webpack)输出的入口 script 默认不带属性,需手动补上 -
<script>写在</body>之后,HTML5 规范视为 parse error,浏览器会把它“纠正”回<body>内 —— 实际效果和写在</body>前一样,别图省事乱放
真正决定 TTI 的不是 script 标签在 HTML 里第几行,而是它是否被浏览器当作同步任务执行。属性比位置重要,执行时机比下载时机关键。一个没加 defer 的水合脚本,哪怕放在文档最后一行,照样会让交互延迟半秒以上。



















