document.getElementById 返回 null 的根本原因是脚本执行过早,未等 DOM 解析完成。内联脚本写在 head 中时 body 和元素均不存在;defer 仅对带 src 的外部脚本生效,内联脚本加 defer 无效;async 脚本不保证顺序且不等待 DOM 解析;动态插入的 script 拥有独立执行上下文,不继承前序脚本变量。

为什么 document.getElementById 返回 null?
不是 DOM 选错了 ID,而是脚本执行得太早。浏览器按 HTML 顺序解析,遇到 <script> 就暂停解析、立即执行——如果脚本写在 <head> 里,此时 <body> 还没开始解析,document.body 或任何 id 元素都不存在。
- 写在
<head>的内联脚本:无法访问document.body、document.getElementById('app')等 - 写在
<body>底部的内联脚本:能安全访问所有已解析的元素 - 外部脚本(
src)放在<head>:同样会阻塞并提前执行,除非加defer
defer 只对带 src 的外部脚本生效
<script src="main.js" defer></script> 会等到 DOM 解析完成再执行,且多个 defer 脚本严格按 HTML 中出现顺序执行;但 <script defer>init();</script> 中的 defer 属性会被浏览器完全忽略——内联脚本加 defer 无效。
- 检查是否漏写了
src:空src或src=""等同于同步阻塞脚本 - 构建工具(如 Vite)可能自动注入
async,覆盖你手动写的defer,需查vite-plugin-html配置 -
type="module"脚本天然具有defer行为,无需重复加属性
混用 async 和同步/defer 脚本会破坏依赖链
async 脚本下载完立刻执行,不保证顺序,也不等 DOM 解析完成。一旦和其它脚本混用,原本靠位置控制的初始化逻辑就可能断裂。
- 错误写法:
<script src="lodash.js" async></script><script src="app.js" defer></script>→app.js可能先执行,调用_.map报ReferenceError -
async适合无依赖的独立逻辑(如统计埋点),不适合工具库 + 业务代码这种强依赖关系 - 若必须动态加载,用
Promise.all([loadScript('a.js'), loadScript('b.js')])显式控制顺序,而不是靠标签排列
动态插入的 <script> 不继承前序脚本上下文
用 document.createElement('script') 插入的脚本,会触发一次全新的全局执行上下文创建——它有自己的变量提升阶段,不感知之前脚本中 let 或 const 的值,也不会“接着上一个脚本继续执行”。
立即学习“前端免费学习笔记(深入)”;
- 默认行为等同于
async = true:下载不阻塞,但执行时机不确定 - 想让它同步执行,需显式设
script.async = false(慎用,会阻塞解析) - 更可靠的做法是监听
script.onload,再插入下一个依赖脚本,形成链式加载
defer 的“假安全感”——看着属性写了,其实毫无作用;还有动态插入脚本后直接读取前一个脚本定义的 const 变量,结果掉进 TDZ 报错。顺序不是靠“写了就有效”,而是靠执行时机与上下文隔离的真实边界。



















