前面几篇讲的都是静态页面:HTML 搭出骨架,CSS 决定长相,但页面一旦显示出来就再也不变了。要让页面能变、能响应、能算东西,需要引入第三种语言——JavaScript。这一篇只做第一步:把一段 JS 放进页面,让它在正确的时刻运行,并且用它找到页面上的某个元素,改掉它的文字和样式。
<script> 是个专门用来放代码的元素
JS 要跑起来,得先有一处地方把它交给浏览器。HTML 里负责这件事的元素是 <script>,它有两种写法:
1<!-- 内联:代码直接写在标签之间 -->
2<script>
3 console.log("我在页面里运行");
4</script>
5
6<!-- 外链:代码放在单独的 .js 文件里 -->
7<script src="app.js"></script>外链写法更常用,因为代码可以独立编辑、被多个页面共用,浏览器也能缓存这个文件。有一点容易踩:写了 src 之后,标签之间的内容会被完全忽略,不会执行。
不带 defer 的脚本会让解析停下来等它
要理解“什么时候执行”,得先回想第 1 篇里的那条主线:浏览器拿到 HTML 后是 一边下载、一边解析 的,解析器从上往下依次处理标签,遇到元素就建一个节点,逐步搭出那棵文档树。
当解析器走到一个不带 async、也不带 defer 的 <script> 时,它会 暂停解析:如果需要外链就先去把文件下载回来,然后执行这段代码,跑完再继续往下解析。这叫解析阻塞(parser-blocking)。
浏览器为什么非要停下来?因为脚本里可以读写页面上的任何东西,而浏览器无法在运行前判断它到底依赖哪些元素。它能给出的唯一保证是:脚本执行时,HTML 里写在它前面的元素都已经存在;写在它后面的还不存在。
这个保证是单向的,它直接解释了两个初学者最常见的现象。
第一,脚本放的位置决定了它能看见什么。 把不带 defer 的 <script> 写在 <head> 里,解析器此时才刚建到 head,body 里的元素一个都还没有。脚本里去找这些元素,结果自然是找不到,拿到 null,接着对 null 取属性就会报 TypeError: Cannot read properties of null (reading 'textContent')。
第二,报错不会让页面垮掉。 JS 出错只中断这一段脚本自己,解析器会继续往下走,页面照样显示出来。所以这种问题的表现往往是“页面看起来一切正常,就是功能没生效”。看到这个报错,第一件事就应该去检查脚本的位置和运行时机,而不是怀疑选择器写错了。
有两种常规修法。
一种是把 <script> 挪到 </body> 之前。此时整份 HTML 已经解析得差不多了,脚本要的元素基本都在。
另一种是加 defer 属性(对外链脚本有效):
1<script src="app.js" defer></script>defer 的效果是:脚本的下载和 HTML 解析 同时进行,不阻塞解析;等整个文档解析完成之后,再按脚本在页面上出现的顺序依次执行。两全其美,所以现代写法常常就是“<script> 写在 <head> 里 + defer”。
async 是另一条路:下载也不阻塞,但 下载完就立刻执行,多个脚本之间的顺序没有保证。它适合彼此独立、互不依赖的脚本;如果脚本之间有先后依赖,用 async 就可能出随机 bug。
这两个属性有个共同的边界:它们 只对外链脚本有效,写在标签里的内联脚本加不加都一样。因为内联代码不需要下载,浏览器没有理由推迟它。
还有一点值得记住:defer 的脚本执行完,浏览器才触发 DOMContentLoaded 事件——也就是“文档解析完毕,可以安全操作 DOM 了”的信号。async 脚本不在这个事件的等待范围内。所以如果你看到别人写:
1document.addEventListener("DOMContentLoaded", () => {
2 // 在这里操作 DOM
3});那就是在用另一种方式把手上的代码延后到“DOM 建好之后”再跑。用了 defer 的脚本不需要再包这一层。
用 CSS 选择器把元素找出来
DOM 建好之后,脚本要通过它去找到具体的元素。这一步的主力方法是 document.querySelector():
1const title = document.querySelector("#title");它的参数就是你写 CSS 时用的那一套选择器字符串——#title 选 id,.note 选 class,nav a 选后代,全部照搬。所以前面那篇讲选择器的内容在这里直接复用,不需要再学一套新语法。
它会返回 文档中第一个匹配的元素;一个都没匹配上,返回 null(不是报错,也不是空对象)。所以“找不到”这件事必须自己判断,否则就会在下一行炸出前面那个 TypeError。找错元素也是常见的“改了但没效果”:querySelector 只按你给的选择器办事,选到的是不是你想改的那个,需要自己确认。
改掉元素里的字和它的样式
拿到元素之后,改文字最直接的属性是 textContent:
1title.textContent = "天气组件已启动";它会把元素里面原有的所有子内容整体替换成一段纯文本。这里 不要用 innerHTML 来改文字:innerHTML 会把字符串当 HTML 解析,如果这段字符串里混进了用户输入,就可能被当作标签或事件属性执行,这是 XSS 攻击的常见入口。改纯文字用 textContent,更安全。
改样式有两条路,区别很重要。
第一条是直接写 element.style:
1title.style.color = "#b3005e";它等价于给这个元素加一条 行内样式。回想 CSS 那篇讲过的层叠顺序:行内样式没有选择器,但它的优先级被当作最高的一档,压过所有普通规则,只有 !important 才能盖过它。这带来两个现实后果:一是脚本改过的颜色,回头再用 class 想改回来是改不动的,冲突排查会变得很难;二是样式散落在 JS 里,一个元素的多个状态会被拆到两个文件里维护。
第二条是改 class,让 CSS 文件继续负责长相:
1title.classList.add("highlight");classList 是读写元素 class 属性的接口,add() / remove() / toggle() 分别对应加、删、切换。它只是往 class 属性里加了一个名字,具体长什么样仍然由 CSS 里的规则决定,层叠照常裁决。样式和结构各归其位,状态也能用“有没有这个 class”来表达。多数情况下应该优先选这条。
一个完整的小例子
1<!DOCTYPE html>
2<html lang="zh-CN">
3<head>
4 <meta charset="utf-8">
5 <title>第一个脚本</title>
6 <link rel="stylesheet" href="style.css">
7 <script src="app.js" defer></script>
8</head>
9<body>
10 <h1 id="title">今天的天气</h1>
11 <p class="note">页面刚打开时,这里是静态的文字。</p>
12</body>
13</html>1/* style.css */
2.highlight {
3 color: #b3005e;
4}1// app.js
2const title = document.querySelector("#title");
3const note = document.querySelector(".note");
4
5title.textContent = "天气组件已启动";
6title.classList.add("highlight");
7note.textContent = "这段文字是脚本运行后替换上去的。";结果是:脚本一执行,标题文字就被换成了“天气组件已启动”并变成玫红色,段落也被替换。这次每次查找都能成功,靠的正是 defer 给出的保证——脚本开始跑的时候,h1 和 p 都已经在 DOM 里了。
小练习:把这个例子里的 <code>defer</code> 去掉,其余不动,会发生什么?
解析器走到 head 里的 <script> 时会停下来执行脚本,而此时 body 还没被解析,所以两个 querySelector 都返回 null。第一行对 null 设 textContent 就抛出 TypeError,脚本到此中断;解析器继续往下走,页面正常显示,只是文字和颜色都没有被改。把脚本挪到 </body> 之前,或者把 defer 加回来,就能恢复正常。
到这里,你手里的页面已经能“在打开时自己改自己”了。但用户点按钮、填表单时发生改变,需要另一套机制:事件。下一篇讲怎么监听事件,让修改发生在用户动作的那一刻,而不是页面刚打开的时候。