# 让页面动起来的第一步：脚本放在哪、什么时候跑，以及怎么找到并改掉一个元素

不带 defer 的脚本会暂停 HTML 解析，defer 与 async 各改掉哪一段时机，以及如何用 CSS 选择器找到元素、改掉它的文字和样式

> DOM · 浏览器工作原理 · CSS 基础 · 约 7 分钟 · 10 月 08 日

## 本篇要点

1. `<script>` 只有两种用法：代码写在标签之间，或用 `src` 外链；写了 `src` 时标签内部的内容会被忽略。
2. 浏览器是一边下载一边解析 HTML 的，遇到不带 `async`、`defer` 的脚本会暂停解析，先下载并执行它，再继续往下解析。
3. 这种暂停的唯一保证是单向的：脚本执行时，写在它前面的元素已经存在，写在它后面的还不存在。
4. 在 `<head>` 里放不带 `defer` 的 `<script>`，`body` 里的元素还不存在，`querySelector` 会返回 `null`，对它取属性就会报 `TypeError: Cannot read properties of null`。
5. JS 报错只中断这一段脚本，解析继续进行，所以典型表现是“页面正常显示，但功能没生效”。
6. `defer` 让脚本并行下载、等整个文档解析完再按出现顺序执行；`async` 是下载完立刻执行、多个脚本顺序不确定，只适合彼此独立的脚本。
7. `defer` 和 `async` 只对外链脚本有效，对内联脚本没有作用。
8. `DOMContentLoaded` 在文档解析完成且所有 `defer` 脚本执行完之后触发，`async` 脚本不在它的等待范围内；用了 `defer` 就不需要再包这个事件监听。
9. `document.querySelector()` 的参数就是 CSS 选择器字符串，返回文档中第一个匹配的元素，没有匹配时返回 `null`。
10. 改文字用 `textContent`；`innerHTML` 会把字符串当 HTML 解析，混入用户输入时可能执行恶意代码。
11. 改样式有两种方式：`element.style` 等于加行内样式，优先级最高、只有 `!important` 能盖过，容易和 CSS 文件里的规则打架；`classList.add()` 只是改 `class` 属性，样式仍由 CSS 负责，通常更可取。

---

前面几篇讲的都是静态页面：HTML 搭出骨架，CSS 决定长相，但页面一旦显示出来就再也不变了。要让页面能变、能响应、能算东西，需要引入第三种语言——JavaScript。这一篇只做第一步：把一段 JS 放进页面，让它在正确的时刻运行，并且用它找到页面上的某个元素，改掉它的文字和样式。

## `<script>` 是个专门用来放代码的元素

JS 要跑起来，得先有一处地方把它交给浏览器。HTML 里负责这件事的元素是 `<script>`，它有两种写法：

```html
<!-- 内联：代码直接写在标签之间 -->
<script>
  console.log("我在页面里运行");
</script>

<!-- 外链：代码放在单独的 .js 文件里 -->
<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` 属性（对外链脚本有效）：

```html
<script src="app.js" defer></script>
```

`defer` 的效果是：脚本的下载和 HTML 解析**同时进行**，不阻塞解析；等整个文档解析完成之后，再按脚本在页面上出现的顺序依次执行。两全其美，所以现代写法常常就是“`<script>` 写在 `<head>` 里 + `defer`”。

`async` 是另一条路：下载也不阻塞，但**下载完就立刻执行，多个脚本之间的顺序没有保证**。它适合彼此独立、互不依赖的脚本；如果脚本之间有先后依赖，用 `async` 就可能出随机 bug。

这两个属性有个共同的边界：它们**只对外链脚本有效**，写在标签里的内联脚本加不加都一样。因为内联代码不需要下载，浏览器没有理由推迟它。

还有一点值得记住：`defer` 的脚本执行完，浏览器才触发 **`DOMContentLoaded`** 事件——也就是“文档解析完毕，可以安全操作 DOM 了”的信号。`async` 脚本不在这个事件的等待范围内。所以如果你看到别人写：

```js
document.addEventListener("DOMContentLoaded", () => {
  // 在这里操作 DOM
});
```

那就是在用另一种方式把手上的代码延后到“DOM 建好之后”再跑。用了 `defer` 的脚本不需要再包这一层。

```mermaid
flowchart LR
  A["解析到 script"] --> B{"有没有 defer 或 async？"}
  B -->|"都没有"| C["暂停解析，下载并立即执行，再继续解析"]
  B -->|"defer"| D["并行下载，继续解析；解析完再按顺序执行"]
  B -->|"async"| E["并行下载，下载完就执行，顺序不确定"]
  D --> F["触发 DOMContentLoaded"]
```

## 用 CSS 选择器把元素找出来

DOM 建好之后，脚本要通过它去找到具体的元素。这一步的主力方法是 `document.querySelector()`：

```js
const title = document.querySelector("#title");
```

它的参数就是你写 CSS 时用的那一套选择器字符串——`#title` 选 id，`.note` 选 class，`nav a` 选后代，全部照搬。所以前面那篇讲选择器的内容在这里直接复用，不需要再学一套新语法。

它会返回**文档中第一个匹配的元素**；一个都没匹配上，返回 `null`（不是报错，也不是空对象）。所以“找不到”这件事必须自己判断，否则就会在下一行炸出前面那个 `TypeError`。找错元素也是常见的“改了但没效果”：`querySelector` 只按你给的选择器办事，选到的是不是你想改的那个，需要自己确认。

## 改掉元素里的字和它的样式

拿到元素之后，改文字最直接的属性是 `textContent`：

```js
title.textContent = "天气组件已启动";
```

它会把元素里面原有的所有子内容整体替换成一段纯文本。这里**不要用 `innerHTML` 来改文字**：`innerHTML` 会把字符串当 HTML 解析，如果这段字符串里混进了用户输入，就可能被当作标签或事件属性执行，这是 XSS 攻击的常见入口。改纯文字用 `textContent`，更安全。

改样式有两条路，区别很重要。

第一条是直接写 `element.style`：

```js
title.style.color = "#b3005e";
```

它等价于给这个元素加一条**行内样式**。回想 CSS 那篇讲过的层叠顺序：行内样式没有选择器，但它的优先级被当作最高的一档，压过所有普通规则，只有 `!important` 才能盖过它。这带来两个现实后果：一是脚本改过的颜色，回头再用 class 想改回来是改不动的，冲突排查会变得很难；二是样式散落在 JS 里，一个元素的多个状态会被拆到两个文件里维护。

第二条是改 class，让 CSS 文件继续负责长相：

```js
title.classList.add("highlight");
```

`classList` 是读写元素 `class` 属性的接口，`add()` / `remove()` / `toggle()` 分别对应加、删、切换。它只是往 `class` 属性里加了一个名字，具体长什么样仍然由 CSS 里的规则决定，层叠照常裁决。样式和结构各归其位，状态也能用“有没有这个 class”来表达。多数情况下应该优先选这条。

## 一个完整的小例子

```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <title>第一个脚本</title>
  <link rel="stylesheet" href="style.css">
  <script src="app.js" defer></script>
</head>
<body>
  <h1 id="title">今天的天气</h1>
  <p class="note">页面刚打开时，这里是静态的文字。</p>
</body>
</html>
```

```css
/* style.css */
.highlight {
  color: #b3005e;
}
```

```js
// app.js
const title = document.querySelector("#title");
const note = document.querySelector(".note");

title.textContent = "天气组件已启动";
title.classList.add("highlight");
note.textContent = "这段文字是脚本运行后替换上去的。";
```

结果是：脚本一执行，标题文字就被换成了“天气组件已启动”并变成玫红色，段落也被替换。这次每次查找都能成功，靠的正是 `defer` 给出的保证——脚本开始跑的时候，`h1` 和 `p` 都已经在 DOM 里了。

<details>
<summary>小练习：把这个例子里的 <code>defer</code> 去掉，其余不动，会发生什么？</summary>

解析器走到 `head` 里的 `<script>` 时会停下来执行脚本，而此时 `body` 还没被解析，所以两个 `querySelector` 都返回 `null`。第一行对 `null` 设 `textContent` 就抛出 `TypeError`，脚本到此中断；解析器继续往下走，页面正常显示，只是文字和颜色都没有被改。把脚本挪到 `</body>` 之前，或者把 `defer` 加回来，就能恢复正常。

</details>

到这里，你手里的页面已经能“在打开时自己改自己”了。但用户点按钮、填表单时发生改变，需要另一套机制：事件。下一篇讲怎么监听事件，让修改发生在用户动作的那一刻，而不是页面刚打开的时候。

## 术语表

- `<script>` 元素：HTML 里专门用来放 JavaScript 的元素，可以内联写代码，也可以用 `src` 指向外部文件。
- 解析阻塞：解析器遇到不带 `defer`、`async` 的脚本时暂停解析，先把它下载并执行完，再继续建 DOM。
- `defer`：只对外链脚本有效，脚本与解析并行下载，等整个文档解析完再按出现顺序执行。
- `async`：只对外链脚本有效，下载完就立刻执行，多个脚本之间不保证顺序。
- `DOMContentLoaded`：文档解析完成、且 `defer` 脚本都执行完之后触发的信号，表示可以安全操作 DOM。
- `document.querySelector()`：用 CSS 选择器在文档里找元素，返回第一个匹配的元素，找不到时返回 `null`。
- `textContent` 与 `innerHTML`：前者把内容当纯文本读写，后者把字符串当 HTML 解析；改文字应使用 `textContent`。
- `classList`：读写元素 `class` 属性的接口，用 `add()`、`remove()`、`toggle()` 增删类名，让 CSS 文件继续负责样式。

## 来源

1. [MDN: script 元素 — defer、async 与解析阻塞行为](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/script)
2. [MDN: DOMContentLoaded 事件 — 触发时机及与 defer、async 的关系](https://developer.mozilla.org/en-US/docs/Web/API/Document/DOMContentLoaded_event)
3. [MDN: Document.querySelector() — 返回首个匹配元素或 null](https://developer.mozilla.org/en-US/docs/Web/API/Document/querySelector)
4. [MDN: Node.textContent — 与 innerText、innerHTML 的区别](https://developer.mozilla.org/en-US/docs/Web/API/Node/textContent)
5. [MDN: Element.innerHTML — 会解析 HTML，以及相关的 XSS 风险](https://developer.mozilla.org/en-US/docs/Web/API/Element/innerHTML)
6. [MDN: HTMLElement.style — 写入行内样式及其在层叠中的优先级](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/style)
7. [MDN: Element.classList — add、remove、toggle 的用法](https://developer.mozilla.org/en-US/docs/Web/API/Element/classList)

---

原文：https://pangzhengboyin.com/articles/script-loading-order-and-queryselector-basics-b95799e3

> **庞征博引** · 想学的，慢慢都会
>
> 庞征博引是把想学的东西写成连载的 AI 学习工具。说出想学什么，它会先了解你的基础，再把主题写成一篇篇 5–10 分钟能读完的文章；边读边问，接下来学什么跟着你走。这篇就是这样写出来的。
>
> 开始你自己的连载 → https://pangzhengboyin.com
