内联脚本默认应该放在 </body> 之前,而不是 <head> 里;只有一类例外——必须在页面首次绘制之前改变外观或环境的脚本(比如暗色模式判定、埋点分流、跳转),才放到 <head> 中,并且要尽量写短。
内联脚本放 head 和放 body 到底有什么区别?
结论:核心区别是是否阻塞 HTML 解析和首次渲染。浏览器是从上到下流式解析 HTML 的,遇到没有 src 的内联 <script> 会立刻暂停解析、执行这段 JS,执行完再继续往下解析。
放在 <head> 里的内联脚本,执行时 <body> 还没被解析出来,此时 document.body 是 null,document.querySelector('#app') 返回 null,任何直接操作 DOM 的代码都会报 TypeError。同时,脚本执行期间浏览器不会绘制后面的内容,用户的感知就是白屏时间变长。
放在 </body> 前则相反:脚本执行时整棵 DOM 树已经构建完毕,可以直接查询元素、绑定事件,不需要再等 DOMContentLoaded。
为什么不能像外部脚本那样加 defer 或 async?
结论:defer 和 async 对没有 src 的内联脚本完全不生效,写了也会被忽略。这两个属性是给外部脚本的下载过程用的——defer 让脚本下载期间不阻塞解析、并在 DOM 解析完成后按文档顺序执行;async 让脚本下载完就立刻执行。
内联脚本没有下载阶段,代码就在 HTML 里,浏览器解析到就得执行,没有"稍后再执行"这个选项。
唯一的例外是 <script type="module">:模块脚本(无论内联还是外链)默认就是延迟执行的,等价于带了 defer。所以下面这种写法不会阻塞解析:
<script type="module">
document.querySelector('#app').textContent = 'ready';
</script>
但要注意,模块脚本默认是延迟的,也就意味着它不能承担"渲染前必须同步执行"的任务,而且模块脚本受 CORS 和 MIME 类型约束,本地 file:// 打开会直接失败。
哪些内联脚本必须放在 head 里?
结论:只有"必须在首次绘制前生效"的脚本才放 <head>,典型有三类。
第一类是防止主题闪烁(FOUC)。如果用户在设置里选了暗色主题,把偏好存在 localStorage,那这段读取和加 class 的代码必须同步跑在渲染之前,否则会先闪一下亮色:
<head>
<script>
if (localStorage.getItem('theme') === 'dark') {
document.documentElement.classList.add('dark');
}
</script>
</head>
这里操作的是 documentElement(即 <html>),它在 <head> 阶段就已经存在,所以不会报错——这是能放 head 的关键前提。
第二类是埋点分流和 A/B 实验分组,需要在上报页面曝光前决定用户进入哪个实验桶。第三类是设备判定后的跳转,比如移动端 UA 重定向。
这三类脚本的共同要求是:只读全局对象(localStorage、navigator、document.documentElement),不碰 <body> 里的元素,代码量控制在几十行以内。
实际项目里怎么组织最稳妥?
结论:按"执行时机"分三类放置,而不是按功能模块分。
- 渲染前必须执行 →
<head> 顶部,只读不写 DOM,尽量控制在 1KB 以内。
- 操作 DOM / 绑定事件 / 初始化组件 →
</body> 前最后一行,此时不需要 DOMContentLoaded 包装。
- 纯配置数据 → 可以用
<script type="application/json" id="config"> 放在 <head>,它不会被当作 JS 执行、不阻塞解析,正文脚本再 JSON.parse 读取。
如果内联脚本因为 CSP(内容安全策略)被拦截,说明站点开了 script-src 且没有 unsafe-inline,这时要给内联脚本加 nonce 属性,服务端每次响应生成随机值并写进 CSP 头,不能用固定值。
内联脚本还有哪些容易被忽略的坑?
结论:内联脚本无法被浏览器缓存,改动一次就影响整个 HTML 文档的缓存命中。
把大段逻辑写进 <script> 标签,意味着每次 HTML 变化都要重新下载这段代码,而外部 JS 文件能被独立缓存、走 CDN。判断标准很简单:超过 30 行的逻辑就该抽成外部文件,用 <script src="..." defer> 引入。
另外 document.write 在解析完成后调用会清空整个文档,不要用。内联脚本里出现字符串 </script>(比如模板字符串里拼 HTML)会提前结束脚本块,需要写成 <\/script> 转义。
一句话收束:默认放 </body> 前,让 DOM 先就绪;只有防闪烁、分流、跳转这类"必须抢在首次渲染前"的同步脚本才进 <head>,并且只读全局对象、控制体积,其余逻辑一律外链加 defer。