在给新手做前端入门分享时,我第一个讲的内容几乎永远是:JavaScript 到底应该写在哪儿。这个问题听着基础,但多基础的问题也架不住真的有人在这一步卡住——HTML 文件里能放脚本的位置太多了,可以写在标签属性里,可以写在<script>标签里,也可以单独丢进一个.js文件再引进来。以前带实习生的时候,他照着教程把代码塞进页面底部的<script>,结果按钮就是不响应;而把同样的逻辑换成onclick属性写法,反而能跑了。他一头雾水跑来问:JS 不是都一样吗,换个位置差别怎么这么大?
如果你也有类似的困惑,这篇文章就是给你写的。我会把 JavaScript 代码的三种编写位置(行内、内部、外部)的写法、执行逻辑、适用场景全部拆开讲透,同时把新手最容易踩的坑——比如脚本没执行完就调 DOM、报Cannot read property of null、内联代码引号打架——用真实的报错过程完整走一遍。看完你不但知道代码该写哪,还能明白为什么有些写法在真实项目中是雷区。
1. 三种写法到底长什么样:直接贴代码对照
先说结论:三种位置分别是指行内写法、内部写法和外部写法。它们表面上只是"代码放在哪"的区别,实际上连执行时机和作用域都不太一样。这一节先把三种写法一次性写出来,后面的章节再逐个拆解背后的原理。
1.1 行内写法:代码直接塞进标签属性
所谓行内写法,最典型的就是把 JavaScript 直接写在 HTML 标签的事件属性里,比如onclick、onmouseover、onchange这些。更冷门一点的历史写法是用javascript:伪协议塞在链接地址里。
<button onclick="alert('Hello World')">点击我</button> <a href="javascript:alert('Hello World')">老式写法,不推荐</a>你看,这就是行内写法:不需要单独的<script>标签,也没有独立文件,JavaScript 代码像字符串一样写在 HTML 属性值里。
这种写法最大的优点只有一个:快。写个 Demo、测试一小段逻辑、临时给某个按钮加个行为,直接在浏览器里改 HTML 就能看到效果,不用新建文件,也不用管 script 标签放在哪。
但它的问题也最明显。代码被字符串化之后,没法复用、没法调试,而且后面我会专门讲到的引号转义问题,能让简单逻辑写成一团乱麻。另外,在真实项目中,行内脚本很容易被浏览器的安全策略(CSP)一票否决——现在很多站点会通过Content-Security-Policy响应头禁止内联脚本执行,你辛辛苦苦写在onclick里的代码,在用户浏览器里根本不会跑,控制台只给你一句Refused to execute inline script。做前端三年以上的人,基本都对这条报错印象深刻。
1.2 内部写法:用 script 标签把代码包起来
内部写法是把 JavaScript 直接写在一个<script>标签内部,放在 HTML 文件里。这也是新手最先接触的形式。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>内部脚本示例</title> <script> function sayHello() { alert('Hello World'); } </script> </head> <body> <button onclick="sayHello()">点击我</button> </body> </html>这种写法的好处是代码和结构在同一个文件里,打开 HTML 就能看完整个页面的逻辑,非常直观。对于只有一个页面的小项目、工具箱页面或者学习阶段,内部脚本完全够用。我见过不少个人工具站,整个页面就一个 HTML 文件,所有逻辑全写在底部<script>里,维护起来没问题,因为代码总量不大。
代价是什么呢?一个页面一份脚本,页面之间无法复用。如果你的网站有十个页面都要用同一个校验函数,你就得在十个 HTML 文件里各抄一份。后期改需求的时候,就得挨个文件搜着改,漏一个就是线上 bug。真正做项目时,这种重复代码会让维护成本指数级上升。
另外有个细节很多人不知道:<script>标签如果同时写了src属性和标签体内的代码,标签体内的代码会被浏览器直接忽略。也就是说,<script src="app.js">alert('我不会执行')</script>这个写法,那个alert永远不会有反应。这个坑我在代码评审里见过不止一次,都是从别处复制代码时不小心带出来的。
1.3 外部写法:独立 JS 文件配合 src 引用
外部写法是把 JavaScript 写进独立的.js文件,然后在 HTML 里用<script src="...">引进来。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>外部脚本示例</title> <script src="js/main.js" defer></script> </head> <body> <button id="btn">点击我</button> </body> </html>// js/main.js function sayHello() { alert('Hello World'); } document.getElementById('btn').addEventListener('click', sayHello);这是现代前端项目里绝对的主流写法,理由很实际:
第一,浏览器缓存。外部 JS 文件被浏览器下载后会缓存,只要文件名不变,用户再次访问你的站点时脚本直接从本地缓存加载,连网络请求都省了。这也是为什么很多团队上线前会强制给文件名加版本号或哈希值,比如main.a1b2c3d4.js——文件名一变,浏览器就知道内容变了,会重新拉取。
第二,代码复用与协作。不同的页面引用同一个 JS 文件,函数只需要维护一份。团队开发时,一个人写页面结构,一个人写业务逻辑,互不干扰。
1.4 三份代码放一起,区别一眼看穿
上面分别介绍,还是不够直观。我直接做一张对照表,把三种写法的关键差异列出来,新手拿这张表去理解就足够了。
| 维度 | 行内写法 | 内部写法 | 外部写法 |
|---|---|---|---|
| 代码位置 | HTML 标签属性内部 | <script>标签内部 | 独立.js文件 |
| 复用性 | 完全无法复用 | 同一页面内可复用 | 跨页面复用 + 浏览器缓存 |
| 执行时机 | 事件触发时才执行 | 浏览器解析到标签就执行 | 浏览器解析到标签(文件加载完成后)执行 |
| 典型场景 | 快速验证、写死 Demo | 单页小工具、学习阶段 | 实际项目、团队协作 |
| 维护成本 | 最高,散落在各标签中 | 中等,页面变大后会失控 | 最低,按文件管理 |
| 与 CSP 的兼容性 | 差,容易被拦截 | 差,容易被拦截 | 好,不受内联限制影响 |
看完这张表你会明白一件事:三种写法不是从丑到美的三个等级,而是适用场景不同。行内和内部写法在特定场合能快速解决问题,但只要涉及真实项目,外部写法几乎必然胜出。
2. 写在"哪里",本质上是在决定执行时机、作用域和渲染阻塞
好多新手觉得位置问题就是"美观问题"——代码写在脑子里也行,反正功能一样。这个认知大错特错。选择代码位置,本质上是在选择脚本的执行时机、变量作用域和页面渲染速度三者之间的平衡。这一节,我把背后的原理拆开。
2.1 从上到下执行:为什么脚本位置会决定你拿到的是元素还是 null
HTML 文件不是被浏览器一口气渲染完的,而是从上到下逐行解析的。以下面这个例子来说:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <script> // 此时 body 里的元素还没被解析到 const box = document.getElementById('box'); console.log(box); // 输出 null </script> </head> <body> <div id="box"></div> </body> </html>很多新手在这里第一次翻车:代码明明写在页面里,box元素明明存在,为什么脚本拿到的却是null?答案就是执行时机。浏览器从上往下解析 HTML,走到<head>里的<script>时,<body>还没开始解析,也就是说<div id="box">此刻压根不存在于 DOM 中,getElementById拿不到节点,返回null。之后不管你对这个null做什么操作,比如box.style.display = 'none',控制台就会抛出一句经典的运行时报错:
Uncaught TypeError: Cannot set properties of null (setting 'display')这也是我在 KPI 里看到"javascript 运行时报错"这个搜索词时最想先展开的场景——这类报错 90% 以上是脚本执行时机和 DOM 构建时机错位导致的。
把脚本挪到</body>之前,情况立刻反转:
<body> <div id="box"></div> <script> const box = document.getElementById('box'); console.log(box); // 输出 div#box </script> </body>因为浏览器解析到页面底部的脚本时,上面的 DOM 已经全部构建完毕,自然就能拿到元素。
2.2 作用域和全局污染:内联代码为什么容易翻车
说到作用域,这里有一个特别容易让新手困惑的点:行内写法的代码,和你页面里<script>中声明的全局变量共享同一个全局环境。什么意思呢?看这个例子:
<script> var count = 10; </script> <button onclick="var count = 20; console.log(count)">点击</button>你一旦点了按钮,全局的count就变成了 20。是的,内联代码里用var声明的变量会直接落到全局作用域里,相当于一个隐式的全局变量声明。这在小型测试页里看不出危害,但脚本一多,两个文件都往全局挂变量,分分钟互相覆盖。我之前接手过一个老项目,页面上三个脚本文件里都有个let data,加载顺序一变,数据就全乱了。排查了半天,最后发现就是变量互相覆盖。
内部脚本和外部脚本也一样:如果在<script>标签顶层直接声明var x = 1,这个x会变成window.x。这也是为什么稍微正式一点的项目都会用模块化手段(ES Module、打包工具),或者至少用 IIFE(立即执行函数)把代码包起来,目的就是不让变量泄漏到全局。这个道理放在"代码写在哪"的讨论里,已经算是进阶建议了,但根基仍然是作用域。
2.3 渲染阻塞:脚本放 head 里会让首屏肉眼可见地变慢
除了执行时机,还有一个容易被忽视的性能问题:脚本会阻塞页面的渲染解析。
浏览器在解析 HTML 的过程中,一旦遇到<script>标签(无论内部还是外部),就会停下手里解析 DOM 的活儿,先等脚本下载完、执行完,再去解析后续的 HTML。为什么?因为脚本可以通过document.write之类的接口往文档里塞内容,浏览器怕你边解析边改文档,干脆先让脚本干完活再继续。
这意味着什么?如果你把一个很大的外部脚本放在<head>里,用户打开页面的那一瞬间会看到一个空白页面,直到这个脚本下载并执行完毕,后面的 CSS 和 HTML 才开始渲染。放在移动端网络环境下,一个几百 KB 的脚本就足以让首屏白屏好几秒。
解决思路有两个方向:一是把脚本从<head>挪到<body>底部,让页面先渲染完,再加载脚本;二是给外部脚本加上defer或async属性。第二条路线我放到第 4 章专门说,因为里面的细节值得单独开一节。
2.4 可维护性:能快速找到上次改的代码在哪,比想象中重要
最后说一个听起来像"软素质"、实际上直接影响开发效率的点:可维护性。写代码这件事,写出来的时间只占很小一部分,绝大部分时间是在读代码、查代码、改代码。如果 JavaScript 像撒芝麻一样落在 HTML 的各个onclick属性里,你打算排查一个 bug 时,只能人工翻遍整个 HTML 文件去找哪一行藏了哪段逻辑。要是项目有几十个页面,这种剧本我已经不想回忆了。
外部文件加上合理的目录结构,能省掉大量检索时间。比如js/utils.js放工具函数,js/api.js放接口封装,js/page/home.js放首屏业务逻辑。出问题的时候,你大概率的定位时间能从"半小时翻页面"压缩到"十秒钟定位文件"。这个差异在新手期不明显,一旦页面超过几百行,就变成碾压级的差距。
3. 新手高频报错:null、is not a function、引号打架,一次讲透
这一章我用三个真实的报错场景,把新手在"代码位置"上最常犯的错误完整走一遍排查流程。你能从这里学到的,不仅是修复方案,更是一套排错思路——下次遇到类似的报错,第一反应不再是"删了重写",而是先判断执行时机和写法到底出了什么问题。
3.1 报错 "Cannot read properties of null":完整排查链路
假设你要给一个按钮绑定点击事件,代码如下:
<head> <script src="js/main.js"></script> </head> <body> <button id="submitButton">提交</button> </body>// js/main.js const button = document.getElementById('submitButton'); button.addEventListener('click', () => console.log('clicked'));浏览器打开页面,控制台直接报红:
Uncaught TypeError: Cannot read properties of null (reading 'addEventListener') at main.js:2:8从我自己的经验出发,新手拿到这个报错一般会做三件事:检查 ID 有没有拼错、检查 JS 文件有没有引入成功、甚至在 HTML 里又加一个同名 id,但问题依旧。因为他们漏掉了最关键的一环——main.js 在 head 里,执行时 body 还没渲染。
正确的排查顺序其实很简单:
第一步,确认报错行对应的是不是 DOM 操作。第二行document.getElementById('submitButton')返回了null,说明此刻 DOM 中没有这个节点。
第二步,检查脚本和执行时机。脚本放在head中,浏览器执行脚本时body尚未解析,拿不到按钮,这个逻辑是确定的。把脚本从 head 移到</body>前面,问题立刻消失。
第三步,如果你不方便移动脚本位置,那就监听 DOM 就绪事件:
document.addEventListener('DOMContentLoaded', function() { const button = document.getElementById('submitButton'); button.addEventListener('click', () => console.log('clicked')); });等整个 DOM 构建完成再去操作节点,效果一样。这个方法在实际项目中很常见,特别是某些脚本必须放在head里的情况——比如统计代码、页面级别的全局配置。但能用defer解决的问题,就别用事件回调包一层,代码会更清爽。
3.2 函数声明与函数表达式:你以为定义好了,其实还没执行到
第二种报错和第一种长得不太一样,但同样和"脚本执行顺序"强相关。看这段代码:
<!-- 第一个 script --> <script> init(); </script><!-- 第二个 script --> <script src="js/app.js"></script>// js/app.js function init() { console.log('initialized'); }浏览器从上到下执行,第一个脚本调用了init(),但此时app.js还没被加载,init函数压根不存在。于是控制台报出第二个经典错误:
Uncaught ReferenceError: init is not defined要理解这个问题,必须分清"函数声明"和"函数表达式"。
// 函数声明:会被整体提升到当前作用域顶部 function initA() { console.log('A'); } // 函数表达式:只有 var 声明会被提升,赋值不会 var initB = function() { console.log('B'); };如果你把initB的调用写在赋值之前,比如:
initB(); // Uncaught TypeError: initB is not a function var initB = function() {};你会得到一个 "is not a function" 而不是 "is not defined"。原因是var initB被提升了,变量存在但值是undefined,调用undefined自然报类型错误。而如果函数写在另一个还没加载的文件里,变量连声明都没有,则报is not defined。这两个报错长得像,但本质完全不同——一个是"变量存在但还不是函数",一个是"变量完全不存在"。
由此得到一个特别重要的经验:多个外部脚本之间存在依赖时,必须严格按照依赖顺序引用。公共工具库放前面,依赖它的业务代码放后面。这也是为什么第 4 章里我会强调,async属性在这种场景下不能乱加。
3.3 内联 onclick 的引号地狱:三层引号互相打架
第三种坑,纯粹是语法层面的,由行内写法的"字符串嵌套"引起。你写属性值的时候,外层已经用了一对双引号(或单引号),里面的 JavaScript 代码又是字符串,还得再用引号,一开始就会撞车。
先看一个能跑的版本:
<button onclick="alert('Hello')">点击</button>外层 HTML 属性用双引号,内层 JS 字符串用单引号,没问题。但你要是想在弹窗里输出一个包含单引号的字符串,比如 "It's OK":
<!-- 直接内层用双引号,会和外层冲突 --> <button onclick="alert("It's OK")">点击</button>浏览器一解析,属性值在第一个双引号处就闭合了,剩下的全是乱套的 HTML。你可能会想:那外层也用单引号呗:
<button onclick='alert("It's OK")'>点击</button>结果又变成了内层单引号和外层单引号撞车。这就是我在标题里说的"引号打架"。正确做法是使用 HTML 实体转义:
<button onclick="alert('It's OK')">点击</button>这段代码看着就劝退人。再往下,一旦参数是变量,转义复杂程度简直雪上加霜:
<button onclick="doSomething('param1', 'param2', 'It's a test')">点击</button>我见过不少真实项目里因为这种引号嵌套写错,导致按钮点击没反应的例子。说实话,到了这个复杂度,已经没有任何理由继续用行内写法了——你完全可以把函数名写在onclick里,把参数处理和复杂逻辑放到内部或外部脚本中:
<!-- HTML 里只需保留一个函数名 --> <button onclick="handleClick()">点击</button>// 内部或外部脚本里处理参数和逻辑 function handleClick() { const message = "It's OK"; alert(message); }这一对比,行内写法的脆弱性就暴露得很彻底。
3.4 从坑里总结出:什么场景下每种写法真正合适
上面三个坑,都是我在实际业务中见过或踩过的。总结一下,避免这些坑的核心原则其实就一句话:行内写法只适合与页面结构无关的极简交互,以及写死的一次性 Demo;一旦逻辑超过一个表达式,就应该搬进 script 标签或外部脚本中。
内部脚本适合的场景:单页面的个人工具站、学习阶段的练习页、原型页快速验证。一旦你需要把同一套逻辑用在多个页面上,就必须抽成外部脚本。而外部脚本是真实项目的默认选择,配合 defer 属性还能避开 DOM 未就绪的问题。
很多新手会纠结"我的代码到底该直接写在 HTML 里,还是建一个 JS 文件",我的回答始终是:先判断复用需求。没有复用需求、项目只有一个文件,内部脚本足够;有任何跨页面复用的苗头,立即切外部脚本,别等到复制粘贴了三次才想起来抽文件。
4. 外部脚本的进阶玩法:defer、async 与模块化加载
前面反复提到"脚本会阻塞渲染"、"多个脚本有依赖时要小心顺序",这一节把外部脚本的加载机制彻底讲透,包括defer、async两个属性,以及现代前端的type="module"写法。这是从"会写"走向"会优化"的关键一步。
4.1 默认加载方式的痛点,到底痛在哪
先明确一点:外部的<script src="...">不带任何特殊属性时,浏览器遇到它,会先下载这个 JS 文件,下载完立即执行,执行完再继续解析后面的 HTML。整个过程里,DOM 解析被完全阻塞。
<head> <script src="js/header.js"></script> </head> <body> <!-- 要等 header.js 下载并执行完,这里才会继续渲染 --> <div>页面内容</div> </body>这在网络较差的时候很致命。试想用户带宽只有几百 KB/s,一个 500KB 的 JS 文件下载就要好几秒,这几个秒里页面上什么都看不见。现代前端框架普遍打包出几百 KB 甚至更大的 bundle,如果还是用这种默认加载方式,首屏体验会非常糟糕。
解决方案看起来很简单:把<script>从<head>挪到</body>前。这确实是最容易执行的优化,但不是没有副作用——脚本必须等到整个页面解析完才开始下载,相当于把下载时间又往后推了。要是能在 HTML 解析的同时,后台慢慢把脚本下载好,等解析完再执行,那才是理想状态。defer和async就是干这个的。
4.2 defer 和 async 的区别:一张图讲不明白的部分用表格说清楚
defer和async都能让脚本变成"异步加载"——浏览器一边继续解析 HTML,一边在后台下载脚本,不会阻塞文档解析。但下载完成之后的执行时机完全不同:
defer 的行为:脚本在后台下载,HTML 文档解析完成后(准确说是 DOMContentLoaded 触发前)按文档顺序依次执行。多个defer脚本的执行顺序和它们在 HTML 里出现的顺序一致。
async 的行为:脚本在后台下载,下载完成后立即执行,不用等 HTML 解析完毕,也不管其他脚本是否已经执行。多个async脚本之间,谁先下载完谁先执行,顺序完全不可控。
| 行为 | defer | async |
|---|---|---|
| 是否阻塞 HTML 解析 | 否,后台下载 | 否,后台下载 |
| 执行时机 | 文档解析完成后 | 下载完成立即执行 |
| 多个脚本执行顺序 | 按文档顺序 | 不保证,下载完就执行 |
| 适用场景 | 依赖 DOM 或依赖其他脚本 | 独立无依赖的脚本 |
这一对比,选择逻辑就很清晰了。如果你的脚本要操作 DOM 里的元素,或者依赖另一个脚本先加载完毕,用defer。比如经典的两个文件:
<script src="js/lib.js" defer></script> <script src="js/app.js" defer></script>lib.js先执行,app.js后执行,而且此时 DOM 已经解析完,两个脚本里能放心操作 DOM。我之前维护过一个运营后台项目,所有页面脚本都加defer,再没出现过"元素为 null"的新手报错。
async适合加载完全独立、不依赖任何页面状态和第三方库的脚本,比如埋点统计、聊天插件、广告脚本。这些脚本什么时候执行都不影响页面主流程,也不指望调用其他脚本里的函数。每次有新手问"能不能给所有脚本都加 async",我都要解释一遍:一旦你的脚本调用了另一个脚本里的函数,async的乱序执行就是定时炸弹。
4.3 type="module":现代项目的加载方式,其实也在回答"写在哪儿"
如果你正在了解现代前端,可能已经见过<script type="module">这个写法。模块化脚本和普通脚本有几个关键区别,其中两点和本文主题直接相关:
第一,type="module"的脚本默认拥有defer行为。也就是说,模块脚本不会被 HTML 解析阻塞,而是等文档解析完再按顺序执行。
第二,模块内部有独立的模块作用域,不会像普通脚本那样把顶层var变量泄漏到全局。
看一个最基础的模块脚本写法:
<script type="module"> import { formatMoney } from './utils.js'; document.getElementById('total').textContent = formatMoney(199.999); </script>// utils.js export function formatMoney(value) { return Number(value).toFixed(2); // 保留两位小数 }这里顺带回应一下热搜词:"javascript 保留两位小数"。你完全可以在外部脚本utils.js里写一个formatMoney工具函数,再用import导入使用。这种模块化组织方式,本质上是把"代码写在哪"提升到了"模块管理"的层面:工具函数放utils.js,页面业务放home.js,再通过 import 串起来。
不过要提醒新手:本地直接用file://协议打开包含import的 HTML 文件,会因为跨域限制报错。想本地预览模块代码,最简单的方式是起一个本地静态服务,比如npx serve或python -m http.server。这个小坑,我当时试了好几种方式才找到方向。
4.4 业务场景实操:canvas 初始化、工具函数和第三方库怎么放
把纯原理落到业务场景里,才有参考价值。这里用 canvas 初始化来举例,因为这是另一条热搜词:"javascript canvas",也是脚本位置问题的高发地。
写 canvas 项目时,你十有八九要做初始化:
const canvas = document.getElementById('gameCanvas'); const ctx = canvas.getContext('2d'); ctx.fillStyle = '#f00'; ctx.fillRect(0, 0, 100, 100);这段代码如果被一个不带属性的<script src="js/game.js"></script>放在<head>里,getElementById('gameCanvas')拿到的是null,初始化直接失败。修复方案就是给脚本加defer:
<head> <script src="js/game.js" defer></script> </head> <body> <canvas id="gameCanvas" width="800" height="600"></canvas> </body>这样脚本会在 DOM 解析完后再执行,canvas元素必然存在。这也是我在自己写 Canvas 小游戏时最常用的姿势。
再举一个工具函数的组织例子。如果一个项目里多个页面都要格式化金额,工具函数就应该放在外部脚本统一维护:
// js/utils.js function formatPrice(value) { return value.toFixed(2); } function discountPrice(price, discount) { return (price * discount).toFixed(2); }页面里怎么用?先引入,再调用:
<script src="js/utils.js" defer></script> <script> const total = formatPrice(299.5); document.getElementById('price').textContent = total; </script>注意这里我用了两个脚本,第二个普通脚本依赖第一个里的全局函数formatPrice,所以不能给它加async。一旦加了,两个脚本的下载完成顺序不固定,formatPrice可能还不存在。这种依赖关系在外部脚本组合里经常出现,判断依据始终是同一句话:有依赖,用 defer;无依赖、纯独立,才考虑 async。
5. 我平时怎么选代码位置:不同阶段、不同场景的实操建议
前面四章把原理、报错、优化都讲完了,最后一章说说我自己在各阶段的实际选择和踩出来的经验。这些建议不完全出于"最佳实践"教条,更多是一个个真实项目教训换来的。
5.1 新手期:先把内部脚本用熟,别急着上工程化工具
给刚入门的前端开发者一句实话:学习阶段真不用急着把所有代码拆成一堆外部文件。本来就还在理解 DOM、事件、函数调用,如果一开始就引入模块化、打包工具、构建流程,反而容易把注意力从"语言本身"挪到"工具链"上。
我建议的学习路径是这样的:前两周,写内部脚本足够,把 JS 语法、变量、函数、DOM 操作先练熟。等到你觉得一个 HTML 文件里的<script>越来越臃肿、或者要复制同样的函数到另一个页面时,再自然过渡到外部脚本。这个过程最好由需求驱动,而不是由焦虑驱动。
5.2 真实项目:外部脚本 + defer + 合理目录,是性价比最高的方案
到了实际项目阶段,我的默认做法非常固定:所有脚本都拆成外部文件,所有业务加载脚本都加defer,目录结构保持清晰。
project/ ├── index.html ├── css/ │ └── style.css └── js/ ├── utils.js ├── api.js └── page/ └── home.js这样一个结构,页面引用方式非常简单:
<script src="js/utils.js" defer></script> <script src="js/api.js" defer></script> <script src="js/page/home.js" defer></script>utils在最前,api依赖utils紧随其后,页面业务代码最后。三个脚本里的函数互相配合,但因为都有defer,它们会按照文档顺序依次执行,DOM 也已经完整,几乎不会有本章前面说的那些报错。说实话,这套方案本身不 fancy,但稳定、可控、容易排查问题。很多公司里真正跑了几年的业务系统,也没用什么前沿技术,靠的就是这种朴素但正确的组织方式。
5.3 特殊场景:CSP 限制内联脚本,WebView 里 OC 与 JS 互相调用
有两类特殊场景,代码位置的选择会直接影响功能能不能跑通,值得单独提一下。
第一类是受 CSP 限制的站点。很多对安全要求高的系统,响应头里设置了Content-Security-Policy,明确禁止内联脚本。你的行内onclick和javascript:伪协议在这种情况下直接失效,控制台会给出拒绝执行的提示。解决办法就是把所有逻辑搬进外部脚本,再用事件监听代替onclick属性。这不仅是一个技术选择,更是安全合规的前提。做政务、金融、后台管理系统的时候,这一条几乎绕不开。
第二类是移动端 WebView 里的 JS 注入时机。做 App 内嵌 H5 时,经常会遇到 iOS 原生(OC 或 Swift)通过 WKWebView 调用页面 JS 函数的场景。OC 侧调用webView.evaluateJavaScript("callFromNative()")时,页面里的callFromNative函数必须已经挂载到全局作用域上。如果页面脚本用的是外部文件且没有defer,在 OC 调用时脚本可能还没执行完,函数根本不存在,调用就会失败。所以在这种场景下,我会把对外暴露的函数显式挂到window上,并确保页面脚本在 DOMContentLoaded 之后再完成全局挂载,或者让原生侧延迟到合适的时机再发起调用。这个过程能顺利跑通,背后依赖的正是"脚本执行时机"的知识——看起来是两种写法的选择,实际拼的是对加载顺序的把握。
5.4 两条保命经验:把 console 用起来,以及给脚本写"加载日志"
最后分享两个小经验,都是调试脚本位置问题的利器。
第一,善用 console 打印"执行标记"。在拿不准脚本到底是没被加载、还是加载了没执行、还是执行了但报错时,可以在脚本不同位置加console.log。比如在外部脚本第一行写console.log('utils.js loaded'),在函数的入口再打一行console.log('formatPrice called')。打开控制台一看,哪个日志出现了、哪个没出现,问题范围立刻缩小。我排查了很多新手的问题,最后都是用这个办法帮他们定位的。
第二,遇到可疑的 null 报错,先在 getElementById 那行打印返回值。比如:
const box = document.getElementById('box'); console.log('box:', box);打开控制台,如果看到box: null,说明是执行时机问题;如果看到元素对象,说明元素拿得到,问题在后面的操作。这种排查思路比瞎猜快得多。还有一次我遇到一个async脚本顺序问题,注释怎么改都没用,最后就是在两个脚本里各加了一行日志,看到执行顺序和预期相反,才最终定位到是async导致的。现在我用console的频率比用 debugger 高得多,基础工具用好,效率并不比花哨工具差。
从行内到内部再到外部,三种 JavaScript 编写位置看起来只是代码放哪的问题,实际背后是执行时机、变量作用域、页面性能、安全策略和项目可维护性的综合权衡。希望这篇内容能帮你把这块地基打牢,以后写代码遇到报错时,多一个排查问题的角度。