先说个很多人踩过的坑:关键JS直接塞在里执行,结果页面白屏了好几秒。这事儿不是玄学,而是浏览器的工作机制决定的。

关键 JS 为什么不能放 里直接执行
浏览器一遇到标签,就会立刻放下手头的HTML解析工作,等脚本下载、解析、执行完,才重新开工。哪怕这段JS只是给一个按钮绑定点击事件,它也会卡住整个DOM树的生成——你明明在HTML里写了首屏渲染的DOM结构,浏览器却迟迟不把它画出来。
有个常见的误区是:脚本很轻量,应该没事。但同步脚本(不加async或defer)无论是内联还是外链,都会阻塞解析,和体积大小没关系。内联脚本反而更危险——因为它省去了下载延迟,执行得更早,阻塞也就来得更早。
实际开发中,这种问题最常见的表现就是:在里调用document.getElementById('main')返回null;或者在DevTools的Performance面板里看到Parse HTML被Script Evaluation长时间中断。这些信号都指向同一个问题:执行时机没对上DOM的构建进度。
defer 和 async 到底该选哪个
聊到这,可能有人会问:那用defer还是async?
核心区别就一句话:defer保证执行顺序,且在DOMContentLoaded之前执行;async下载完立刻执行,顺序不可控,可能在DOM还没解析完时就跑起来了。
用法其实很清晰:
- 依赖DOM的初始化代码(比如
initApp()、renderHeader())——必须用defer - 分析统计、埋点、广告SDK这类无依赖、可随时执行的——用
async就行 - 多个
defer脚本按HTML中间出现顺序执行;async脚本谁先下完谁先跑,顺序不可控 - 注意一个小细节:
type="module"的脚本默认行为等同defer,不用额外加
如何让关键 JS 真正“解耦”于 HTML 结构
解耦的本质,不是去掉JS,而是把执行时机从“靠手动判断”变成“由浏览器自动调度”。
具体做法很实在:
- 用
DOMContentLoaded事件包裹逻辑,而不是单纯把脚本放到底部“碰运气” - 避免写
onclick="doSomething()"这种内联事件,改用addEventListener动态绑定——哪怕只绑一次,代码的可维护性也比前者强得多 - 需要提前获取的资源(比如配置、用户信息),用
内联数据,而不是拼字符串塞进JS里 - 如果必须操作特定元素,优先用
data-属性做标记,而不是依赖id或class名字——名字一改,JS就失效
构建阶段能做的解耦:动态 import() 与入口分离
真正的解耦发生在打包环节。把关键路径JS拆成「主入口」和「功能模块」,用import()按需触发,而不是一股脑全加载进来。
举个例子:首屏只需要渲染列表,那编辑弹窗、导出Excel的逻辑就不该打包进main.js。
- 路由级拆分:
const Page = await import('./pages/home.js') - 组件级懒加载:
const Modal = await import('./components/ExportModal.js') - 注意:动态
import()返回Promise,必须处理loading状态,否则会出现白屏 - Vite/Webpack会自动把
import()转成独立chunk,配合preload可以提前拉取关键模块
这里最容易被忽视的一点:解耦不是为了写得爽,而是为了让“关键JS”真正变小。如果main.js仍然包含了所有业务逻辑,只是换了个加载方式,那就是换汤不换药。
