JavaScript DOM 完全指南(三):高级工程篇
本篇由《JavaScript DOM 完全指南》拆分而来,覆盖十三至十九章、总结与参考资料。深入 Shadow DOM、安全、DOM CRUD、几何坐标、加载生命周期、表单与 Event Loop,完成专业级 DOM 知识闭环。
目录
- [十三、Shadow DOM 与 Web Components](#十三、Shadow DOM 与 Web Components)
- [13.1 Shadow DOM 简介](#13.1 Shadow DOM 简介)
- [13.2 Web Components 四大支柱](#13.2 Web Components 四大支柱)
- [13.3 本章归纳](#13.3 本章归纳)
- [十四、DOM 安全最佳实践](#十四、DOM 安全最佳实践)
- [14.1 XSS 防护](#14.1 XSS 防护)
- [14.2 安全的 DOM 操作清单](#14.2 安全的 DOM 操作清单)
- [14.3 Content Security Policy(CSP)](#14.3 Content Security Policy(CSP))
- [14.4 DOM 安全检查清单](#14.4 DOM 安全检查清单)
- [14.5 本章归纳](#14.5 本章归纳)
- [十五、DOM 节点增删改克隆(CRUD 完全指南)](#十五、DOM 节点增删改克隆(CRUD 完全指南))
- [15.1 节点的创建](#15.1 节点的创建)
- [15.2 节点的插入](#15.2 节点的插入)
- [15.3 节点的删除](#15.3 节点的删除)
- [15.4 节点的替换](#15.4 节点的替换)
- [15.5 节点的克隆](#15.5 节点的克隆)
- [15.6 综合实战:动态列表 CRUD](#15.6 综合实战:动态列表 CRUD)
- [15.7 本章归纳](#15.7 本章归纳)
- [十六、DOM 几何尺寸与坐标系统](#十六、DOM 几何尺寸与坐标系统)
- [16.1 三套属性族详解](#16.1 三套属性族详解)
- [16.2 offset 属性族](#16.2 offset 属性族)
- [16.3 client 属性族](#16.3 client 属性族)
- [16.4 scroll 属性族](#16.4 scroll 属性族)
- [16.5 getBoundingClientRect()](#16.5 getBoundingClientRect())
- [16.6 实战:Tooltip 精准定位](#16.6 实战:Tooltip 精准定位)
- [16.7 本章归纳](#16.7 本章归纳)
- [十七、文档加载生命周期与 script 加载策略](#十七、文档加载生命周期与 script 加载策略)
- [17.1 文档就绪状态:readyState](#17.1 文档就绪状态:readyState)
- [17.2 DOMContentLoaded vs load](#17.2 DOMContentLoaded vs load)
- [17.3 script 标签的三种模式](#17.3 script 标签的三种模式)
- [17.4 动态加载脚本](#17.4 动态加载脚本)
- [17.5 样式表与 FOUC](#17.5 样式表与 FOUC)
- [17.6 本章归纳](#17.6 本章归纳)
- [十八、表单 DOM 深度解析](#十八、表单 DOM 深度解析)
- [18.1 表单元素访问](#18.1 表单元素访问)
- [18.2 常用表单控件属性](#18.2 常用表单控件属性)
- [18.3 表单事件](#18.3 表单事件)
- [18.4 表单验证 API](#18.4 表单验证 API)
- [18.5 FormData API](#18.5 FormData API)
- [18.6 实战:完整表单验证](#18.6 实战:完整表单验证)
- [18.7 本章归纳](#18.7 本章归纳)
- [十九、Event Loop 与 DOM 渲染机制](#十九、Event Loop 与 DOM 渲染机制)
- [19.1 浏览器的线程架构](#19.1 浏览器的线程架构)
- [19.2 Event Loop 模型](#19.2 Event Loop 模型)
- [19.3 微任务 vs 宏任务](#19.3 微任务 vs 宏任务)
- [19.4 为什么连续修改 DOM 不会触发多次重排](#19.4 为什么连续修改 DOM 不会触发多次重排)
- [19.5 requestAnimationFrame 与渲染时机](#19.5 requestAnimationFrame 与渲染时机)
- [19.6 MutationObserver 与微任务](#19.6 MutationObserver 与微任务)
- [19.7 nextTick 原理](#19.7 nextTick 原理)
- [19.8 本章归纳](#19.8 本章归纳)
- 总结
- 参考资料
十三、Shadow DOM 与 Web Components
13.1 Shadow DOM 简介
Shadow DOM 是一种封装技术,允许创建隐藏的、隔离的 DOM 树,附加到常规 DOM 元素上。
名词解析:
| 术语 | 说明 |
|---|---|
| Shadow Host | 承载 Shadow DOM 的常规 DOM 元素 |
| Shadow Tree | Shadow DOM 内部的 DOM 树 |
| Shadow Root | Shadow Tree 的根节点 |
| Light DOM | 常规的、非 Shadow DOM |
| Scoped CSS | Shadow DOM 内的样式只作用于内部 |
javascript
// 创建 Shadow DOM
const host = document.createElement('div');
const shadowRoot = host.attachShadow({ mode: 'closed' });
shadowRoot.innerHTML = `
<style>
p { color: red; } /* 只影响 Shadow DOM 内部 */
</style>
<p>Shadow DOM 内容</p>
`;
document.body.appendChild(host);
代码解释: attachShadow({ mode: 'open' }) 允许从外部通过 host.shadowRoot 访问,'closed' 则禁止。Shadow DOM 内的样式、ID、class 都不会泄露到外部,反之亦然,实现了真正的组件封装 。<slot> 元素允许 Light DOM 内容投射到 Shadow DOM 内,实现灵活组合。
完整示例:自定义组件
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Shadow DOM 组件</title>
<style>
body {
font-family: Arial, sans-serif;
padding: 20px;
background: #f5f5f5;
}
.container {
max-width: 800px;
margin: 0 auto;
}
/* 外部样式不影响 Shadow DOM 内部 */
user-card h3 {
color: red; /* 无效 */
}
</style>
</head>
<body>
<div class="container">
<h2>🎭 Shadow DOM 组件演示</h2>
<user-card name="张三" title="前端工程师" avatar="👨💻">
<p slot="bio">专注于 Web 开发,擅长 JavaScript 和 CSS。</p>
</user-card>
<user-card name="李四" title="UI 设计师" avatar="👨🎨">
<p slot="bio">热爱设计,追求完美的用户体验。</p>
</user-card>
</div>
<script>
class UserCard extends HTMLElement {
constructor() {
super();
// 创建 Shadow DOM
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
const name = this.getAttribute('name') || '未知用户';
const title = this.getAttribute('title') || '暂无职位';
const avatar = this.getAttribute('avatar') || '👤';
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
background: white;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
overflow: hidden;
margin: 20px 0;
transition: transform 0.3s ease;
}
:host(:hover) {
transform: translateY(-5px);
}
.card-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 30px;
text-align: center;
color: white;
}
.avatar {
font-size: 64px;
margin-bottom: 10px;
}
.name {
font-size: 24px;
font-weight: bold;
margin: 0;
}
.title {
opacity: 0.9;
margin-top: 5px;
}
.card-body {
padding: 20px;
}
::slotted(p) {
color: #666;
line-height: 1.6;
margin: 0;
}
</style>
<div class="card-header">
<div class="avatar">${avatar}</div>
<h3 class="name">${name}</h3>
<p class="title">${title}</p>
</div>
<div class="card-body">
<slot name="bio">暂无简介</slot>
</div>
`;
}
}
// 注册自定义元素
customElements.define('user-card', UserCard);
</script>
</body>
</html>
代码解释: <user-card> 是自定义元素,内部样式完全隔离 ,外部 h3 { color: red } 无法影响。:host 伪类选择器控制组件自身 样式。::slotted(p) 选择投射进来的 <p> 内容。<slot name="bio"> 定义插槽,外部用 slot="bio" 匹配。connectedCallback 是元素插入 DOM 时的生命周期钩子,适合做初始化。优势:组件开发者不必担心样式冲突,使用者也不必了解内部实现。
13.2 Web Components 四大支柱
| 技术 | 作用 | 浏览器支持 |
|---|---|---|
| Custom Elements | 定义自定义 HTML 元素 | Chrome/FF/Safari/Edge(IE 需 polyfill) |
| Shadow DOM | 封装组件的 DOM 和样式 | 同上 |
| HTML Templates | 声明式模板结构 | 原生支持 |
| ES Modules | 组件间复用与导出 | 现代浏览器 |
html
<!-- Web Components 完整示例 -->
<template id="card-template">
<style>
:host { display: block; }
</style>
<div class="card">
<slot></slot>
</div>
</template>
<script>
class MyComponent extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
const template = document.getElementById('card-template');
this.shadowRoot.appendChild(template.content.cloneNode(true));
}
}
customElements.define('my-component', MyComponent);
</script>
13.3 本章归纳
| 特性 | 说明 |
|---|---|
| 样式隔离 | Shadow DOM 内 CSS 不外泄,外部 CSS 不内侵 |
| 语义化 | 自定义元素名必须包含连字符(如 user-card) |
| 生命周期 | connectedCallback、disconnectedCallback、attributeChangedCallback |
| 插槽 | <slot> 与 slot 属性实现内容投射 |
十四、DOM 安全最佳实践
14.1 XSS 防护
跨站脚本攻击(XSS) 是最常见的 Web 安全漏洞之一,DOM 操作是主要攻击面。
javascript
// ❌ 危险 - 直接拼接 HTML
element.innerHTML = userInput; // 用户输入可能包含 <script>
// ✅ 安全 - 使用 textContent
element.textContent = userInput;
// ✅ 安全 - 需要 HTML 时先转义
function escapeHTML(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
element.innerHTML = escapeHTML(userInput);
// ✅ 安全 - 使用 DOM API 创建元素
const span = document.createElement('span');
span.textContent = userInput;
element.appendChild(span);
代码解释: innerHTML 会解析字符串中的 HTML 标签与脚本,若内容含用户输入则极易 XSS 。textContent 只设置文本,自动转义,是最安全 的文本插入方式。createElement + appendChild 同样安全。若必须用 innerHTML,请先转义 < > & " ' 等字符。
完整示例:XSS 演示与防护
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>XSS 防护演示</title>
<style>
.container {
max-width: 800px;
margin: 20px auto;
padding: 20px;
}
.demo-section {
margin: 30px 0;
padding: 20px;
background: #f9f9f9;
border-radius: 8px;
}
.input-group {
margin: 15px 0;
}
input[type="text"] {
width: 70%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
}
button {
padding: 10px 20px;
cursor: pointer;
background: #4CAF50;
color: white;
border: none;
border-radius: 4px;
}
.result-box {
margin-top: 15px;
padding: 15px;
border: 2px solid #ddd;
border-radius: 4px;
min-height: 60px;
background: white;
}
.safe {
border-color: #4CAF50;
}
.dangerous {
border-color: #f44336;
}
.warning {
padding: 10px;
background: #fff3e0;
border-left: 4px solid #ff9800;
margin: 10px 0;
}
</style>
</head>
<body>
<div class="container">
<h2>🔒 XSS 防护演示</h2>
<div class="warning">
⚠️ 本页面仅用于演示,实际攻击中脚本会窃取 Cookie、重定向等
</div>
<div class="demo-section">
<h3>❌ 危险做法(innerHTML)</h3>
<div class="input-group">
<input type="text" id="dangerInput" placeholder="输入 <img src=x onerror=alert(1)>">
<button onclick="testDangerous()">执行</button>
</div>
<div class="result-box dangerous" id="dangerResult"></div>
</div>
<div class="demo-section">
<h3>✅ 安全做法(textContent)</h3>
<div class="input-group">
<input type="text" id="safeInput" placeholder="输入任意内容">
<button onclick="testSafe()">执行</button>
</div>
<div class="result-box safe" id="safeResult"></div>
</div>
<div class="demo-section">
<h3>✅ 安全做法(DOM API)</h3>
<div class="input-group">
<input type="text" id="domApiInput" placeholder="输入任意内容">
<button onclick="testDOMApi()">执行</button>
</div>
<div class="result-box safe" id="domApiResult"></div>
</div>
</div>
<script>
function testDangerous() {
const input = document.getElementById('dangerInput').value;
const result = document.getElementById('dangerResult');
// ❌ 危险:直接使用 innerHTML
result.innerHTML = '输入内容: ' + input;
}
function testSafe() {
const input = document.getElementById('safeInput').value;
const result = document.getElementById('safeResult');
// ✅ 安全:使用 textContent
result.textContent = '输入内容: ' + input;
}
function testDOMApi() {
const input = document.getElementById('domApiInput').value;
const result = document.getElementById('domApiResult');
// ✅ 安全:使用 DOM API
const text = document.createTextNode('输入内容: ' + input);
result.innerHTML = '';
result.appendChild(text);
}
// HTML 转义函数
function escapeHTML(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// CSP(内容安全策略)演示
// 在 HTTP 头中设置: Content-Security-Policy: default-src 'self'; script-src 'self'
</script>
</body>
</html>
代码解释: 危险做法 用 innerHTML 直接拼接用户输入,输入 <img src=x onerror=alert(1)> 会触发 XSS。安全做法 用 textContent,所有特殊字符都转义为文本。DOM API 用 createTextNode 同样安全。生产环境 应配合 CSP(Content-Security-Policy) 头限制脚本来源,HttpOnly Cookie 防止窃取,输入验证 与输出编码缺一不可。
14.2 安全的 DOM 操作清单
| 场景 | 危险操作 | 安全替代 |
|---|---|---|
| 插入文本 | innerHTML = userInput |
textContent = userInput |
| 插入 HTML | innerHTML += userHTML |
createElement + 转义 |
| 设置属性 | el.href = userUrl |
验证 URL 白名单 |
| 执行脚本 | eval(userCode) |
禁用 eval,用 new Function 或沙箱 |
| JSON | eval(jsonStr) |
JSON.parse(jsonStr) |
| 事件 | el.onclick = new Function(userCode) |
事件绑定预定义函数 |
14.3 Content Security Policy(CSP)
CSP 是 HTTP 响应头,指定哪些资源可以加载,有效防范 XSS。
http
# 只允许同源脚本
Content-Security-Policy: default-src 'self'; script-src 'self'
# 允许特定域名
Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com
# 禁止内联脚本
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'
# 报告模式(不拦截,只报告)
Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-report
代码解释: default-src 'self' 默认只允许同源资源。script-src 限制脚本来源,'unsafe-inline' 允许内联事件(不推荐)。report-uri 指定违规报告接收地址。CSP 不向后兼容,引入前需测试现有第三方资源。
14.4 DOM 安全检查清单
markdown
## 部署前检查
- [ ] 所有用户输入在使用 `innerHTML` 前已转义
- [ ] 使用 `textContent` 代替 `innerHTML` 处理纯文本
- [ ] 验证所有 URL 参数(如跳转链接、iframe src)
- [ ] 禁用 `eval()`、`Function()` 构造器
- [ ] 配置 CSP 头限制资源来源
- [ ] 设置 Cookie `HttpOnly`、`Secure`、`SameSite`
- [ ] 使用 `setAttribute` 而非直接属性设置用户输入
- [ ] 验证 `postMessage` 来源(`event.origin`)
- [ ] 避免使用 `location.href` 直接拼接用户输入
- [ ] 敏感操作需 CSRF Token
14.5 本章归纳
| 原则 | 说明 |
|---|---|
| 永不信任用户输入 | 始终验证、转义、白名单 |
| 最小权限原则 | CSP 限制资源来源,HttpOnly 防窃取 |
| 优先安全 API | textContent > innerHTML,JSON.parse > eval |
| 纵深防御 | 输入验证 + 输出编码 + CSP + Cookie 保护 |
十五、DOM 节点增删改克隆(CRUD 完全指南)
操作 DOM 的核心能力:创建 、插入 、删除 、替换 、克隆节点。这是每位前端工程师都必须牢固掌握的基础,也是框架底层运作的基石。
15.1 节点的创建
15.1.1 创建元素节点
javascript
// 创建一个 <div> 元素
const div = document.createElement('div');
div.textContent = '我是新创建的 div';
div.className = 'box';
document.body.appendChild(div);
// 创建 SVG 元素需要指定命名空间
const svgNS = 'http://www.w3.org/2000/svg';
const circle = document.createElementNS(svgNS, 'circle');
circle.setAttribute('cx', '50');
circle.setAttribute('cy', '50');
circle.setAttribute('r', '40');
代码解释: createElement 创建的节点处于"游离"状态,不在 DOM 树中 ,不触发任何布局/绘制。只有执行 appendChild / insertBefore 等插入操作后才真正进入文档,才会引发一次重排。SVG 及 MathML 等命名空间元素必须用 createElementNS 创建,否则标签名虽对但接口不正确,SVG 属性无法生效。
15.1.2 创建文本节点
javascript
const textNode = document.createTextNode('纯文本内容,特殊字符 <script> 会被自动转义');
div.appendChild(textNode);
// 对比:textContent 赋值等效,但更简洁
div.textContent = '纯文本内容,特殊字符 <script> 会被自动转义';
代码解释: createTextNode 是最安全的"写入用户输入"方式------它永远不会被解析为 HTML,<、> 等字符会自动转义为实体。比 innerHTML 拼字符串安全,适合所有输出用户可控数据的场景。
15.1.3 创建文档片段(DocumentFragment)
javascript
// DocumentFragment 是轻量级的"离屏容器"
const fragment = document.createDocumentFragment();
const data = ['Apple', 'Banana', 'Cherry', 'Date'];
data.forEach(name => {
const li = document.createElement('li');
li.textContent = name;
fragment.appendChild(li); // 插入 fragment,不触发重排
});
// 只有这一行会触发真实 DOM 操作
document.getElementById('list').appendChild(fragment);
// 注意:fragment 插入后会被清空,无法复用
代码解释: DocumentFragment 本身不属于 DOM 树,在其上执行多次 appendChild 不会触发回流 。将 fragment 插入真实 DOM 时,浏览器只需一次布局计算。适合需要用 createElement 批量插入节点的场景(若只需 HTML 字符串,innerHTML 或 insertAdjacentHTML 更简洁)。插入后 fragment 被清空,不要试图复用。
15.2 节点的插入
15.2.1 传统插入 API
javascript
const parent = document.getElementById('parent');
const newEl = document.createElement('p');
newEl.textContent = '新段落';
// 追加到父节点末尾(最常用)
parent.appendChild(newEl);
// 在参考节点之前插入
const refEl = document.getElementById('ref');
parent.insertBefore(newEl, refEl);
// 插入到参考节点之后(原生无 insertAfter,常见手法)
parent.insertBefore(newEl, refEl.nextSibling);
15.2.2 现代插入 API(推荐)
javascript
const target = document.getElementById('target');
const newEl = document.createElement('div');
newEl.textContent = '使用现代 API 插入';
// ① before / after:在目标元素前/后插入(相邻兄弟)
target.before(newEl); // 插入到 target 之前
target.after(newEl); // 插入到 target 之后
// ② prepend / append:插入到子节点列表开头/末尾
target.prepend(newEl); // 第一个子节点
target.append(newEl); // 最后一个子节点
// 上述方法均接受多参数,也接受字符串(自动转为文本节点)
target.append('文本1', newEl, '文本2');
15.2.3 insertAdjacentHTML / insertAdjacentElement / insertAdjacentText
javascript
const el = document.getElementById('el');
// 四个位置:
// 'beforebegin' ------ el 前面的兄弟位置
// 'afterbegin' ------ el 内部第一个子节点前
// 'beforeend' ------ el 内部最后一个子节点后(等同 innerHTML+=)
// 'afterend' ------ el 后面的兄弟位置
el.insertAdjacentHTML('beforeend', '<strong>批量插入 HTML</strong>');
el.insertAdjacentText('afterbegin', '安全插入纯文本');
// 与 innerHTML += 相比的优势:
// ✅ 不会销毁现有子节点(保留事件监听器)
// ✅ 性能更好(不重新解析全部内容)
代码解释: innerHTML += 会先序列化整棵子树,再全部销毁,再重新解析 ------绑在子节点上的事件监听器全部丢失,性能极差。insertAdjacentHTML('beforeend', html) 只在末尾追加解析,不破坏现有节点 ,是追加 HTML 的首选方式。对于用户输入,改用 insertAdjacentText 或 createTextNode,禁止拼入 HTML。
| 方法 | 接受类型 | 是否解析 HTML | 安全性 |
|---|---|---|---|
appendChild |
Node | --- | ✅ 安全 |
append |
Node / string | 字符串→文本节点 | ✅ 安全 |
innerHTML = |
string | ✅ 解析 HTML | ⚠️ 需转义 |
insertAdjacentHTML |
string | ✅ 解析 HTML | ⚠️ 需转义 |
insertAdjacentText |
string | ❌ 不解析 | ✅ 安全 |
textContent = |
string | ❌ 不解析 | ✅ 安全 |
15.3 节点的删除
javascript
// 现代 API(推荐)
const el = document.getElementById('toDelete');
el.remove(); // 直接删除自身,无需引用父节点
// 传统 API(需要父节点)
const parent = el.parentNode;
parent.removeChild(el);
注意事项:
javascript
// ⚠️ 删除前要解绑"外部持有"的事件监听器,避免内存泄漏
const btn = document.getElementById('btn');
function handleClick() { /* ... */ }
btn.addEventListener('click', handleClick);
// 删除时先解绑(如果监听器绑在外部或有闭包持有元素)
btn.removeEventListener('click', handleClick);
btn.remove();
// ✅ 使用 AbortController 批量清理更优雅
const controller = new AbortController();
btn.addEventListener('click', handleClick, { signal: controller.signal });
// 需要移除时:
controller.abort(); // 一次性移除所有使用该 signal 的监听器
btn.remove();
15.4 节点的替换
javascript
const oldEl = document.getElementById('old');
const newEl = document.createElement('section');
newEl.textContent = '替换后的内容';
// 现代 API
oldEl.replaceWith(newEl); // 用 newEl 替换 oldEl
// 传统 API
oldEl.parentNode.replaceChild(newEl, oldEl);
15.5 节点的克隆
javascript
const original = document.getElementById('card');
// 浅克隆:只复制元素本身,不包含子节点
const shallowClone = original.cloneNode(false);
// 深克隆:复制元素及其全部子树(最常用)
const deepClone = original.cloneNode(true);
document.getElementById('container').appendChild(deepClone);
深克隆的陷阱:
javascript
const el = document.getElementById('withId');
el.id = 'unique-id';
const clone = el.cloneNode(true);
// ⚠️ 克隆会复制 id 属性!必须手动修改或删除,避免页面出现重复 id
clone.id = 'unique-id-clone';
clone.removeAttribute('id'); // 或直接移除
// ⚠️ 克隆不会复制 addEventListener 绑定的事件监听器
// 只有行内 onclick 属性会被复制(不推荐使用行内事件)
clone.addEventListener('click', () => console.log('克隆节点被点击'));
document.body.appendChild(clone);
15.6 综合实战:动态列表 CRUD
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>DOM CRUD 实战</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; max-width: 600px; margin: 40px auto; padding: 0 20px; }
h2 { margin-bottom: 16px; }
.input-row { display: flex; gap: 8px; margin-bottom: 16px; }
.input-row input { flex: 1; padding: 8px 12px; border: 1px solid #ccc; border-radius: 6px; font-size: 14px; }
.input-row button { padding: 8px 16px; background: #4f46e5; color: #fff; border: none; border-radius: 6px; cursor: pointer; }
.input-row button:hover { background: #4338ca; }
#taskList { list-style: none; display: flex; flex-direction: column; gap: 8px; }
.task-item {
display: flex; align-items: center; gap: 10px;
padding: 10px 14px; background: #f9fafb;
border: 1px solid #e5e7eb; border-radius: 8px;
}
.task-item.done { opacity: 0.5; text-decoration: line-through; }
.task-item span { flex: 1; font-size: 14px; }
.task-item .edit-btn { background: #0ea5e9; color: #fff; border: none; padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 12px; }
.task-item .del-btn { background: #ef4444; color: #fff; border: none; padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 12px; }
.task-item input[type="checkbox"] { width: 16px; height: 16px; cursor: pointer; }
#emptyHint { text-align: center; color: #9ca3af; padding: 30px 0; font-size: 14px; display: none; }
</style>
</head>
<body>
<h2>任务列表</h2>
<div class="input-row">
<input type="text" id="taskInput" placeholder="输入新任务..." maxlength="60">
<button id="addBtn">添加</button>
</div>
<ul id="taskList"></ul>
<p id="emptyHint">暂无任务,添加一条试试 👆</p>
<script>
const taskList = document.getElementById('taskList');
const taskInput = document.getElementById('taskInput');
const addBtn = document.getElementById('addBtn');
const emptyHint = document.getElementById('emptyHint');
/* ── 创建单条任务节点 ── */
function createTaskItem(text) {
const li = document.createElement('li');
li.className = 'task-item';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
const span = document.createElement('span');
span.textContent = text; // 安全:textContent 不解析 HTML
const editBtn = document.createElement('button');
editBtn.className = 'edit-btn';
editBtn.textContent = '编辑';
const delBtn = document.createElement('button');
delBtn.className = 'del-btn';
delBtn.textContent = '删除';
/* ── 完成切换 ── */
checkbox.addEventListener('change', () => {
li.classList.toggle('done', checkbox.checked);
});
/* ── 编辑(inline 替换节点) ── */
editBtn.addEventListener('click', () => {
const newText = prompt('编辑任务', span.textContent);
if (newText !== null && newText.trim()) {
span.textContent = newText.trim();
}
});
/* ── 删除 ── */
delBtn.addEventListener('click', () => {
li.remove();
syncHint();
});
li.append(checkbox, span, editBtn, delBtn);
return li;
}
/* ── 添加任务 ── */
function addTask() {
const text = taskInput.value.trim();
if (!text) { taskInput.focus(); return; }
const item = createTaskItem(text);
taskList.appendChild(item); // 只触发一次 DOM 插入
taskInput.value = '';
taskInput.focus();
syncHint();
}
/* ── 控制空状态提示 ── */
function syncHint() {
emptyHint.style.display = taskList.children.length === 0 ? 'block' : 'none';
}
addBtn.addEventListener('click', addTask);
taskInput.addEventListener('keydown', e => { if (e.key === 'Enter') addTask(); });
// 初始化:添加两条示例
['完成 DOM CRUD 学习', '熟练使用 DocumentFragment'].forEach(t => {
taskList.appendChild(createTaskItem(t));
});
syncHint();
</script>
</body>
</html>
代码解释: 所有文本内容通过 textContent 写入,防范 XSS 。任务节点用 createElement 逐件组装,append 一次插入所有子节点(单次 DOM 操作)。删除直接 li.remove(),无需持有父节点引用。syncHint 在增删后检查 children.length 切换空状态提示,避免不必要的 DOM 查询 (taskList 已缓存)。这种模式可无缝迁移到 React/Vue 的组件函数中,原理完全相同。
15.7 本章归纳
| 操作 | 现代 API(推荐) | 传统 API | 备注 |
|---|---|---|---|
| 创建元素 | createElement |
--- | 游离态,不触发回流 |
| 批量插入 | DocumentFragment |
--- | 只触发一次回流 |
| 追加子节点 | append / prepend |
appendChild |
现代 API 支持多参数与字符串 |
| 相邻插入 | before / after |
insertBefore |
before/after 更直观 |
| 追加 HTML | insertAdjacentHTML |
innerHTML += |
前者不销毁现有子节点 |
| 删除自身 | remove() |
parentNode.removeChild |
现代 API 更简洁 |
| 替换 | replaceWith |
replaceChild |
同上 |
| 克隆 | cloneNode(true/false) |
--- | 注意 id 重复与事件丢失 |
十六、DOM 几何尺寸与坐标系统
前端精确布局、拖拽、动画、碰撞检测等场景都需要精准读取元素的尺寸与位置。DOM 提供了三套属性族(
offset*、client*、scroll*)以及getBoundingClientRect(),每套用途各异。
16.1 三套属性族详解
┌─────────────────── offsetParent 坐标系 ──────────────────────┐
│ │
│ offsetLeft / offsetTop ←──── 距 offsetParent 的距离 │
│ │
│ ┌─────── 边框(border) ────────────────────────────────┐ │
│ │ clientLeft / clientTop ← 左/上边框宽度 │ │
│ │ ┌──── padding ──────────────────────────────────┐ │ │
│ │ │ │ │ │
│ │ │ clientWidth / clientHeight │ │ │
│ │ │ = 内容区 + padding(不含滚动条、边框) │ │ │
│ │ │ │ │ │
│ │ │ scrollWidth / scrollHeight │ │ │
│ │ │ = 实际内容总尺寸(含溢出部分) │ │ │
│ │ │ │ │ │
│ │ │ scrollLeft / scrollTop │ │ │
│ │ │ = 已滚动的距离(可读写) │ │ │
│ │ └───────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ offsetWidth / offsetHeight = 内容+padding+border(含滚动条)│
└───────────────────────────────────────────────────────────────┘
三大属性族对比:
| 属性族 | 包含内容 | 典型用途 |
|---|---|---|
offset* |
内容 + padding + border + 滚动条 | 元素占据的总视觉空间 |
client* |
内容 + padding(不含边框/滚动条) | 可视内容区大小 |
scroll* |
实际滚动内容尺寸 / 已滚动距离 | 判断是否到达底部 |
16.2 offset 属性族
javascript
const el = document.getElementById('box');
// 相对 offsetParent 的偏移量(不含 margin)
console.log(el.offsetLeft); // 距最近定位祖先的左边距
console.log(el.offsetTop); // 距最近定位祖先的顶边距
// 元素总尺寸(含 border + padding + 滚动条宽度,不含 margin)
console.log(el.offsetWidth); // 可见宽度
console.log(el.offsetHeight); // 可见高度
// 最近的"定位"祖先(position 非 static),null 表示到 body
console.log(el.offsetParent);
获取元素相对页面的绝对位置(经典算法):
javascript
function getPageOffset(el) {
let left = 0, top = 0;
while (el) {
left += el.offsetLeft;
top += el.offsetTop;
el = el.offsetParent;
}
return { left, top };
}
// ⚠️ 此方法在有 transform 的祖先时不准确,推荐用 getBoundingClientRect
16.3 client 属性族
javascript
const el = document.getElementById('box');
// 左/上边框宽度(通常为 1px 或 0)
console.log(el.clientLeft); // left border 宽度
console.log(el.clientTop); // top border 宽度
// 内容区 + padding(不含边框、滚动条)
console.log(el.clientWidth);
console.log(el.clientHeight);
// 获取视口尺寸(最可靠写法)
const viewportW = document.documentElement.clientWidth;
const viewportH = document.documentElement.clientHeight;
// 等价:window.innerWidth(含滚动条) vs clientWidth(不含滚动条)
16.4 scroll 属性族
javascript
const el = document.getElementById('scrollBox');
// 实际内容的总宽高(可能大于 clientWidth)
console.log(el.scrollWidth);
console.log(el.scrollHeight);
// 已滚动的距离(可读写!)
console.log(el.scrollLeft);
console.log(el.scrollTop);
// 检测是否滚动到底部
function isAtBottom(el) {
return Math.abs(el.scrollHeight - el.scrollTop - el.clientHeight) < 1;
}
// 强制滚动到顶部
el.scrollTop = 0;
// 平滑滚动到底部
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
页面级滚动距离:
javascript
// 获取页面已滚动距离(标准写法)
const scrollY = window.scrollY; // 或 window.pageYOffset(旧版兼容)
const scrollX = window.scrollX;
// 滚动到指定位置
window.scrollTo({ top: 500, behavior: 'smooth' });
window.scrollBy({ top: 100, behavior: 'smooth' });
16.5 getBoundingClientRect()
getBoundingClientRect() 返回元素相对于当前视口(viewport)的坐标和尺寸,是最精确的现代 API。
javascript
const el = document.getElementById('box');
const rect = el.getBoundingClientRect();
/*
DOMRect 对象:
{
top: 元素顶边距视口顶部的距离(可负,表示已滚出视口上方)
bottom: 元素底边距视口顶部的距离
left: 元素左边距视口左侧的距离
right: 元素右边距视口左侧的距离
width: 元素宽度(= right - left)
height: 元素高度(= bottom - top)
x: 等同 left
y: 等同 top
}
*/
// 转为页面绝对坐标(加上当前滚动量)
const pageX = rect.left + window.scrollX;
const pageY = rect.top + window.scrollY;
性能警告: getBoundingClientRect() 会强制触发样式计算和布局 (即"强制同步布局"),不应在循环中频繁调用,或在 requestAnimationFrame 之外与写入操作交替调用(参见第十章)。
16.6 实战:Tooltip 精准定位
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Tooltip 精准定位</title>
<style>
body { font-family: system-ui, sans-serif; padding: 80px 40px; }
.trigger {
display: inline-block;
padding: 8px 16px;
background: #4f46e5; color: #fff;
border-radius: 6px; cursor: pointer;
margin: 8px;
}
#tooltip {
position: fixed;
background: #1f2937; color: #fff;
padding: 6px 12px;
border-radius: 6px;
font-size: 13px;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s;
white-space: nowrap;
z-index: 9999;
}
#tooltip::after {
content: '';
position: absolute;
top: 100%; left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: #1f2937;
}
</style>
</head>
<body>
<div id="tooltip"></div>
<button class="trigger" data-tip="这是第一个按钮的提示">按钮 A</button>
<button class="trigger" data-tip="第二个按钮,内容稍长一些">按钮 B</button>
<button class="trigger" data-tip="Hello World">按钮 C</button>
<script>
const tooltip = document.getElementById('tooltip');
document.querySelectorAll('.trigger').forEach(btn => {
btn.addEventListener('mouseenter', e => {
const tip = btn.dataset.tip;
if (!tip) return;
tooltip.textContent = tip; // 先写内容,确保宽高正确
tooltip.style.opacity = '1';
// 在读取尺寸之前必须先写,否则读到旧值
const rect = btn.getBoundingClientRect();
const tRect = tooltip.getBoundingClientRect();
// 定位:水平居中于触发按钮上方
let left = rect.left + (rect.width - tRect.width) / 2;
let top = rect.top - tRect.height - 10;
// 防止超出视口左右边界
left = Math.max(8, Math.min(left, window.innerWidth - tRect.width - 8));
tooltip.style.left = left + 'px';
tooltip.style.top = top + 'px';
});
btn.addEventListener('mouseleave', () => {
tooltip.style.opacity = '0';
});
});
</script>
</body>
</html>
代码解释: 先 textContent 写入提示文字(会影响 tooltip 宽度),再调用 getBoundingClientRect() 读取尺寸------这是先写后读 的正确顺序,避免读到过期布局数据。position: fixed + viewport 坐标直接定位,无需关心祖先元素滚动。边界裁剪 Math.max/min 防止 tooltip 出屏。
16.7 本章归纳
元素总可见尺寸
内容区尺寸
滚动内容总高
已滚动距离
相对视口精确坐标
相对页面绝对坐标
视口尺寸不含滚动条
视口尺寸含滚动条
我要做什么?
offsetWidth / offsetHeight
clientWidth / clientHeight
scrollHeight
scrollTop / scrollLeft 可写
getBoundingClientRect
rect.top + scrollY
document.documentElement.clientWidth
window.innerWidth
| 属性 | 是否只读 | 触发回流 | 用途 |
|---|---|---|---|
offsetWidth/Height |
✅ 只读 | ✅ 触发 | 元素占位空间 |
clientWidth/Height |
✅ 只读 | ✅ 触发 | 可视内容区 |
scrollTop/Left |
❌ 可写 | 写入触发 | 读写滚动位置 |
getBoundingClientRect() |
✅ 只读 | ✅ 触发 | 精确视口坐标 |
性能法则: 所有几何属性读取都会触发"强制同步布局"。批量先读后写 ,或用
requestAnimationFrame将写操作推迟到下一帧,可有效避免 Layout Thrashing。
十七、文档加载生命周期与 script 加载策略
理解页面加载顺序,是解决"DOM 未就绪就操作"、"脚本阻塞白屏"、"资源加载顺序"等常见 Bug 的关键。
17.1 文档就绪状态:readyState
javascript
// document.readyState 有三个阶段:
// 'loading' HTML 正在解析
// 'interactive' HTML 解析完毕,子资源(图片等)仍在加载
// 'complete' 所有资源加载完毕
console.log(document.readyState); // 脚本执行时的当前状态
document.addEventListener('readystatechange', () => {
console.log('readyState:', document.readyState);
if (document.readyState === 'interactive') {
// DOM 已就绪,等价于 DOMContentLoaded
initUI();
}
if (document.readyState === 'complete') {
// 等价于 window.load
initHeavyFeatures();
}
});
17.2 DOMContentLoaded vs load
javascript
// ① DOMContentLoaded:HTML 解析完毕,DOM 树构建完成
// 图片、CSS、iframe 仍可能未加载
// ✅ 推荐:大多数 DOM 操作在此绑定
document.addEventListener('DOMContentLoaded', () => {
console.log('DOM 就绪,可以操作节点了');
document.getElementById('btn').addEventListener('click', handleClick);
});
// ② load:页面所有资源(图片、CSS、iframe、字体)全部加载完毕
// ⚠️ 通常比 DOMContentLoaded 慢几百毫秒至数秒
window.addEventListener('load', () => {
console.log('所有资源加载完成');
// 适合:需要知道图片实际尺寸、操作 iframe 内容等
const img = document.getElementById('banner');
console.log(`图片真实尺寸: ${img.naturalWidth}×${img.naturalHeight}`);
});
// ③ beforeunload:用户离开页面前
window.addEventListener('beforeunload', (e) => {
if (hasUnsavedChanges) {
e.preventDefault();
e.returnValue = ''; // 触发浏览器"是否离开"对话框
}
});
// ④ unload:页面即将卸载(不推荐,已被 pagehide + visibilitychange 取代)
触发时机对比:
HTML 开始解析
│
▼
[同步脚本执行,阻塞解析]
│
▼
DOM 树构建完成 ──► DOMContentLoaded 触发
│
▼
图片、CSS、字体加载完毕 ──► window.load 触发
17.3 script 标签的三种模式
这是前端性能优化中最重要的知识点之一:
html
<!-- ① 默认(无属性):同步,阻塞 HTML 解析 -->
<script src="app.js"></script>
<!-- 浏览器停止解析 HTML → 下载 → 执行 → 继续解析 -->
<!-- ② defer:延迟执行,不阻塞解析 -->
<script src="app.js" defer></script>
<!-- 浏览器继续解析 HTML,并行下载 JS → HTML 解析完成后按顺序执行 -->
<!-- 执行时机:DOM 就绪后(DOMContentLoaded 之前) -->
<!-- ✅ 现代项目推荐:保证顺序,DOM 就绪后执行 -->
<!-- ③ async:异步,不阻塞解析,下载完立即执行 -->
<script src="analytics.js" async></script>
<!-- 浏览器继续解析 HTML,并行下载 JS → 下载完成立即执行(中断解析) -->
<!-- 执行顺序不保证!适合独立脚本(统计、广告) -->
可视化对比:
HTML 解析 ██████████████████████████████
│ │
普通 <script>: [下载+执行] ██████ 然后才继续解析 ───────►
│
defer: HTML 解析 ██████████████████████████████
并行下载 ████████ │
[DOM就绪后执行]►
│
async: HTML 解析 ███████████████ 中断 █████████
并行下载 ████████ 执行 ───────────►
| 属性 | 下载 | 执行时机 | 保证顺序 | 适用场景 |
|---|---|---|---|---|
| 无 | 同步 | 立即(阻塞) | ✅ | 关键同步脚本(head 中慎用) |
defer |
并行 | DOM 就绪后 | ✅ | 大多数业务脚本(推荐) |
async |
并行 | 下载完立即 | ❌ | 独立脚本:统计、广告 |
17.4 动态加载脚本
javascript
// 运行时按需加载 JS
function loadScript(src) {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = src;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
// 使用
loadScript('https://cdn.example.com/chart.min.js')
.then(() => {
// 库已加载,可以使用
const chart = new Chart(ctx, config);
})
.catch(err => console.error('脚本加载失败', err));
// 现代替代方案:import()
async function initChart() {
const { Chart } = await import('./chart.js');
return new Chart(ctx, config);
}
17.5 样式表与 FOUC
html
<!-- ✅ 正确:CSS 放 <head>,避免 FOUC(Flash of Unstyled Content)-->
<head>
<link rel="stylesheet" href="styles.css">
</head>
<!-- ⚠️ 错误:CSS 放 <body> 末尾,用户会先看到无样式页面 -->
CSSOM 与 DOM 的依赖关系:
HTML 解析 ──► DOM 树
CSS 解析 ──► CSSOM 树
DOM + CSSOM ──► 渲染树(Render Tree) ──► 布局 ──► 绘制
- CSS 不阻塞 DOM 解析,但阻塞渲染(必须等 CSSOM 构建完才能生成渲染树)
- CSS 阻塞其后同步 JS 的执行(JS 可能会读取样式,必须等 CSSOM 就绪)
17.6 本章归纳
JS CSS DOM 浏览器 JS CSS DOM 浏览器 解析 HTML → 构建 DOM 并行解析 CSS → 构建 CSSOM 阻塞同步 JS 执行 defer 脚本等待 DOM DOMContentLoaded 合并 DOM+CSSOM 图片/字体等资源加载完毕 window.load
十八、表单 DOM 深度解析
表单是 Web 中用户输入的核心场所。深入理解表单元素的 DOM API,是开发高质量表单验证、受控输入和多步骤表单的基础。
18.1 表单元素访问
javascript
// 通过 document.forms 集合访问表单(支持索引和 name 属性)
const form = document.forms[0]; // 第一个表单
const formByName = document.forms['login']; // <form name="login">
// 通过 form.elements 访问表单控件
const username = form.elements['username']; // <input name="username">
const allInputs = form.elements; // HTMLFormControlsCollection
console.log(allInputs.length);
18.2 常用表单控件属性
javascript
const input = document.getElementById('myInput');
// 通用属性
input.value; // 当前值(可读写)
input.defaultValue; // HTML 中 value 属性的原始值
input.disabled; // 是否禁用
input.readOnly; // 是否只读
input.required; // 是否必填
input.name; // name 属性
// input[type="checkbox"] / input[type="radio"]
const cb = document.getElementById('myCheckbox');
cb.checked; // 当前选中状态(可读写)
cb.defaultChecked; // HTML 中 checked 属性的原始值
cb.indeterminate; // 部分选中状态(仅脚本可设置)
// select
const sel = document.getElementById('mySelect');
sel.value; // 当前选中的 option 的 value
sel.selectedIndex; // 当前选中项的索引(-1 表示未选)
sel.options; // HTMLOptionsCollection
sel.multiple; // 是否多选
// textarea
const ta = document.getElementById('myTextarea');
ta.value; // 当前内容
ta.selectionStart; // 光标/选区起始位置
ta.selectionEnd; // 选区结束位置
18.3 表单事件
javascript
const form = document.getElementById('myForm');
const input = document.getElementById('email');
// input:每次值变化立即触发(输入、粘贴、删除)
input.addEventListener('input', e => {
console.log('实时值:', e.target.value);
validateEmail(e.target.value);
});
// change:值改变且失焦后触发(select/checkbox/radio 则立即触发)
input.addEventListener('change', e => {
console.log('确认改变:', e.target.value);
});
// focus / blur:获焦 / 失焦(不冒泡)
input.addEventListener('focus', () => input.parentElement.classList.add('focused'));
input.addEventListener('blur', () => input.parentElement.classList.remove('focused'));
// focusin / focusout:等价于 focus/blur,但会冒泡(支持事件委托)
form.addEventListener('focusin', e => e.target.closest('.field')?.classList.add('active'));
form.addEventListener('focusout', e => e.target.closest('.field')?.classList.remove('active'));
// submit:表单提交(最重要的事件)
form.addEventListener('submit', e => {
e.preventDefault(); // 阻止默认跳转行为
if (!validateForm()) return;
sendFormData(new FormData(form));
});
// reset:表单重置
form.addEventListener('reset', () => {
console.log('表单已重置到初始值');
});
18.4 表单验证 API
javascript
const input = document.getElementById('email');
// 内置约束验证(HTML5 Constraint Validation API)
input.checkValidity(); // 是否满足约束,返回 boolean
input.reportValidity(); // 显示浏览器原生错误提示,返回 boolean
input.validity; // ValidityState 对象(详细错误信息)
input.validationMessage; // 浏览器生成的错误文本
// ValidityState 的各项布尔属性
const v = input.validity;
v.valid; // 是否完全有效
v.valueMissing; // required 但未填
v.typeMismatch; // 值格式与 type 不符(如 email 格式错误)
v.tooShort; // 少于 minlength
v.tooLong; // 超过 maxlength
v.rangeUnderflow; // 小于 min
v.rangeOverflow; // 大于 max
v.patternMismatch; // 不匹配 pattern
v.customError; // 调用了 setCustomValidity
// 自定义错误提示
input.setCustomValidity('邮箱格式不正确,请重新输入'); // 设置(触发 customError)
input.setCustomValidity(''); // 清除自定义错误(必须清除,否则永远无效!)
18.5 FormData API
javascript
const form = document.getElementById('myForm');
// 从 form 元素直接构建(自动收集所有 name 控件)
const formData = new FormData(form);
// 读取
formData.get('username'); // 第一个值
formData.getAll('hobby'); // 所有同名值(checkbox 等)
formData.has('email'); // 是否包含某 key
for (const [key, value] of formData) {
console.log(key, value);
}
// 修改
formData.set('username', '新名字'); // 覆盖
formData.append('tag', 'js'); // 追加(允许同名)
formData.delete('secret'); // 删除
// 发送(fetch 会自动设置 Content-Type: multipart/form-data)
fetch('/api/submit', {
method: 'POST',
body: formData
});
// 发送为 JSON
fetch('/api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(Object.fromEntries(formData))
});
18.6 实战:完整表单验证
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>表单验证实战</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; max-width: 480px; margin: 40px auto; padding: 0 20px; }
h2 { margin-bottom: 24px; }
.field { margin-bottom: 18px; }
label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 6px; color: #374151; }
input, select {
width: 100%; padding: 10px 12px;
border: 1.5px solid #d1d5db; border-radius: 8px;
font-size: 14px; transition: border-color 0.2s;
}
input:focus, select:focus { outline: none; border-color: #4f46e5; }
.error-msg { font-size: 12px; color: #ef4444; margin-top: 4px; display: none; }
.field.invalid input, .field.invalid select { border-color: #ef4444; }
.field.invalid .error-msg { display: block; }
.field.valid input, .field.valid select { border-color: #10b981; }
button[type="submit"] {
width: 100%; padding: 12px;
background: #4f46e5; color: #fff;
border: none; border-radius: 8px; font-size: 15px; cursor: pointer;
margin-top: 8px;
}
button[type="submit"]:hover { background: #4338ca; }
#successMsg { display: none; color: #10b981; text-align: center; font-weight: 600; margin-top: 16px; }
</style>
</head>
<body>
<h2>注册账号</h2>
<form id="registerForm" novalidate>
<div class="field" data-field="username">
<label for="username">用户名(4-20 位字母/数字)</label>
<input type="text" id="username" name="username" minlength="4" maxlength="20"
pattern="[a-zA-Z0-9]+" required>
<p class="error-msg"></p>
</div>
<div class="field" data-field="email">
<label for="email">邮箱</label>
<input type="email" id="email" name="email" required>
<p class="error-msg"></p>
</div>
<div class="field" data-field="password">
<label for="password">密码(至少 8 位)</label>
<input type="password" id="password" name="password" minlength="8" required>
<p class="error-msg"></p>
</div>
<div class="field" data-field="confirm">
<label for="confirm">确认密码</label>
<input type="password" id="confirm" name="confirm" required>
<p class="error-msg"></p>
</div>
<button type="submit">注册</button>
</form>
<p id="successMsg">✅ 注册成功!</p>
<script>
const form = document.getElementById('registerForm');
// 各字段的自定义验证规则
const validators = {
username(val) {
if (!val) return '用户名不能为空';
if (val.length < 4) return '用户名至少 4 个字符';
if (!/^[a-zA-Z0-9]+$/.test(val)) return '只允许字母和数字';
return '';
},
email(val) {
if (!val) return '邮箱不能为空';
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val)) return '邮箱格式不正确';
return '';
},
password(val) {
if (!val) return '密码不能为空';
if (val.length < 8) return '密码至少 8 个字符';
return '';
},
confirm(val) {
if (!val) return '请再次输入密码';
if (val !== form.elements['password'].value) return '两次密码不一致';
return '';
}
};
function validateField(input) {
const name = input.name;
const wrapper = input.closest('.field');
const errEl = wrapper.querySelector('.error-msg');
const error = validators[name]?.(input.value) ?? '';
if (error) {
wrapper.classList.add('invalid');
wrapper.classList.remove('valid');
errEl.textContent = error;
} else {
wrapper.classList.remove('invalid');
wrapper.classList.add('valid');
errEl.textContent = '';
}
return !error;
}
// 失焦时验证单个字段
form.addEventListener('focusout', e => {
if (e.target.name && validators[e.target.name]) {
validateField(e.target);
}
});
// 提交时全量验证
form.addEventListener('submit', e => {
e.preventDefault();
const inputs = [...form.querySelectorAll('input[name]')];
const allValid = inputs.every(validateField);
if (allValid) {
const data = Object.fromEntries(new FormData(form));
delete data.confirm;
console.log('提交数据:', data);
document.getElementById('successMsg').style.display = 'block';
form.reset();
inputs.forEach(inp => inp.closest('.field').classList.remove('valid'));
}
});
</script>
</body>
</html>
代码解释: 使用 novalidate 禁用浏览器原生提示,完全接管验证逻辑,实现一致的跨浏览器体验。验证器函数映射(validators 对象)分离验证逻辑与 DOM 操作,易于单元测试。focusout 事件冒泡,配合事件委托,实现"离开某字段即验证"的即时反馈。提交时用 FormData + Object.fromEntries 获取结构化数据,比手动读取每个字段更健壮。
18.7 本章归纳
| 场景 | 推荐做法 |
|---|---|
| 实时反馈 | input 事件 + 防抖 |
| 失焦验证 | focusout(冒泡,可委托) |
| 提交验证 | submit + e.preventDefault() |
| 收集数据 | new FormData(form) |
| 发送 JSON | Object.fromEntries(formData) |
| 自定义错误 | setCustomValidity() + 清空 '' |
十九、Event Loop 与 DOM 渲染机制
理解 Event Loop 不只是应对面试题------它直接解释了为什么"在同一个函数里连续修改 DOM 只会触发一次重排",以及为什么
setTimeout(fn, 0)能让 DOM 更新先于 JS 回调执行。
19.1 浏览器的线程架构
浏览器是多进程、多线程架构,与 DOM 最相关的是渲染进程(Renderer Process),它包含:
| 线程 | 职责 |
|---|---|
| 主线程(Main Thread) | HTML/CSS 解析、JavaScript 执行、样式计算、布局、绘制 |
| 合成线程(Compositor Thread) | 将图层合成为最终帧,处理滚动/缩放(不阻塞主线程) |
| 光栅化线程池(Raster Threads) | 将图层内容光栅化为位图 |
| 网络线程 | 管理网络请求 |
JavaScript 是单线程的 ,运行在主线程上。这意味着 JS 执行与 DOM 渲染共享同一个线程------JS 执行时,浏览器无法同时进行布局或绘制。
19.2 Event Loop 模型
┌─────────────────── 渲染主线程 ───────────────────┐
│ │
│ Call Stack(调用栈) │
│ ┌────────────────┐ │
│ │ 当前执行的代码 │ ◄── 同步代码在这里执行 │
│ └────────────────┘ │
│ │ 空了? │
│ ▼ │
│ Microtask Queue(微任务队列) │
│ ┌─────────────────────────────────────┐ │
│ │ Promise.then / MutationObserver / │ │
│ │ queueMicrotask │ ◄── 优先执行
│ └─────────────────────────────────────┘ │
│ │ 全清空 │
│ ▼ │
│ 渲染步骤(可选,浏览器决定) │
│ Style → Layout → Paint → Composite │
│ │ │
│ ▼ │
│ Macrotask Queue(宏任务队列/任务队列) │
│ ┌─────────────────────────────────────┐ │
│ │ setTimeout / setInterval / │ │
│ │ MessageChannel / I/O / UI Events │ │
│ └─────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────┘
一次 Event Loop 的完整循环:
- 从宏任务队列取一个任务执行(调用栈)
- 执行完毕后,清空整个微任务队列(每次执行完一个微任务还需检查是否有新微任务)
- 浏览器判断是否需要渲染(通常 16.67ms 一帧,约 60fps)
- 若需要渲染:执行
requestAnimationFrame回调 → 样式计算 → 布局 → 绘制 → 合成 - 回到步骤 1
19.3 微任务 vs 宏任务
javascript
console.log('1 同步');
setTimeout(() => console.log('4 宏任务 setTimeout'), 0);
Promise.resolve()
.then(() => console.log('3 微任务 Promise.then'))
.then(() => console.log('3.5 微任务 第二个 then'));
queueMicrotask(() => console.log('2.5 微任务 queueMicrotask'));
console.log('2 同步');
// 输出顺序:1 → 2 → 2.5 → 3 → 3.5 → 4
关键规则:
- 当前调用栈清空后,所有微任务优先执行完毕,才会进行渲染和执行下一个宏任务
Promise.then、MutationObserver、queueMicrotask是微任务setTimeout、setInterval、requestAnimationFrame、DOM 事件回调 是宏任务
19.4 为什么连续修改 DOM 不会触发多次重排
javascript
// 同步代码块中的多次 DOM 修改
const el = document.getElementById('box');
el.style.width = '100px'; // 标记"需要重新布局"
el.style.height = '100px'; // 同上
el.style.color = 'red'; // 标记"需要重新绘制"
// ↑ 以上三行都在同一个宏任务/微任务中执行
// 浏览器不会在每行后面立即重排,而是把"需要重排"标记积累
// 等到 JS 执行完毕(调用栈清空、微任务队列清空)后,
// 才在渲染步骤中一次性计算布局和绘制
这是浏览器的批量更新 (Batched Updates)机制,也是 React 的合成事件、Vue 的 nextTick 的设计依据。
19.5 requestAnimationFrame 与渲染时机
javascript
// setTimeout(fn, 0) 与 rAF 的本质区别
setTimeout(() => {
// 在某个宏任务队列轮次执行
// 可能刚好在渲染帧之间,也可能跳帧
el.style.opacity = '1';
}, 0);
requestAnimationFrame(() => {
// 在浏览器即将渲染下一帧之前执行(渲染步骤第一步)
// 与屏幕刷新率同步,60fps 约 16.67ms 执行一次
el.style.opacity = '1'; // 保证下一帧渲染时生效
});
rAF 实现流畅动画:
javascript
function animate(element, from, to, duration) {
let startTime = null;
function step(timestamp) {
if (!startTime) startTime = timestamp;
const elapsed = timestamp - startTime;
const progress = Math.min(elapsed / duration, 1);
// easeInOut 缓动函数
const eased = progress < 0.5
? 2 * progress * progress
: -1 + (4 - 2 * progress) * progress;
element.style.transform = `translateX(${from + (to - from) * eased}px)`;
if (progress < 1) {
requestAnimationFrame(step); // 递归,每帧更新
}
}
requestAnimationFrame(step);
}
animate(document.getElementById('ball'), 0, 300, 600);
19.6 MutationObserver 与微任务
MutationObserver 的回调是以微任务 方式触发的,这与 setTimeout 的宏任务机制有本质区别:
javascript
const observer = new MutationObserver(mutations => {
// 这是微任务!在 DOM 变更后、渲染前执行
// 所有在同一个宏任务中发生的 DOM 变更会被合并到一次回调
console.log('DOM 变更(微任务):', mutations.length, '次变更');
});
observer.observe(document.body, { childList: true, subtree: true });
// 以下两次 DOM 操作会被合并为一次 MutationObserver 回调
document.body.appendChild(document.createElement('div'));
document.body.appendChild(document.createElement('span'));
// 回调中 mutations.length === 2,但只触发一次回调
19.7 nextTick 原理
Vue 的 nextTick / React 的 flushSync 本质上都是利用微任务或宏任务的执行时机,让 DOM 更新后再执行某些操作:
javascript
// 手写一个简化版 nextTick
function nextTick(fn) {
// 用微任务(Promise)在 DOM 操作批量完成后执行
return Promise.resolve().then(fn);
}
// 使用场景:更新数据后立即读取 DOM 尺寸
function updateList(data) {
renderList(data); // 同步 DOM 更新
nextTick(() => {
// DOM 已更新,但浏览器尚未渲染(微任务中)
const height = listEl.scrollHeight;
console.log('新列表高度:', height);
});
}
19.8 本章归纳
宏任务队列 渲染步骤 微任务队列 JS 调用栈 宏任务队列 渲染步骤 微任务队列 JS 调用栈 执行同步代码(DOM 修改) 空时,取微任务 清空所有微任务(Promise/MutationObserver) rAF → Style → Layout → Paint 取下一个宏任务(setTimeout/事件) 循环往复
| 类型 | 触发方式 | 与渲染的关系 | 典型代表 |
|---|---|---|---|
| 同步代码 | 直接执行 | 渲染前 | 普通函数调用 |
| 微任务 | 调用栈清空后 | 渲染前 | Promise.then、queueMicrotask |
| 渲染帧 | 每约 16ms | 在微任务后 | requestAnimationFrame |
| 宏任务 | 渲染后队列调度 | 渲染后 | setTimeout、DOM 事件 |
工程价值: 连续多次 DOM 修改不需要手动合并------浏览器会批量重排。真正需要避免的是"读写交替"(Layout Thrashing),而不是"多次写入"。遇到需要在 DOM 更新后立即读取几何信息的场景,用
requestAnimationFrame将读取推迟到下一帧渲染后。
总结
知识体系一张图
JavaScript DOM
节点模型
Document Element Text Comment Attribute
获取元素
getElementById querySelector HTMLCollection NodeList
结构遍历
children parentElement siblings
属性
内置属性 getAttribute dataset
样式
style getComputedStyle classList className
CRUD操作
createElement insertAdjacentHTML remove cloneNode DocumentFragment
几何坐标
offsetWidth clientWidth scrollTop getBoundingClientRect
加载生命周期
DOMContentLoaded load defer async readyState
表单DOM
FormData 验证API input事件 submit
事件机制
事件流 冒泡 捕获 事件委托 preventDefault stopPropagation
渲染性能
回流 重绘 合成 强制同步布局 RAF
Event Loop
宏任务 微任务 批量更新 nextTick
现代 API
IntersectionObserver MutationObserver ResizeObserver
实战
全选反选 时钟 Tab 全屏滚动 购物车
高级案例
虚拟滚动 拖放 无限滚动
组件化
Shadow DOM Web Components
安全
XSS 防护 CSP 安全 API
工程化
缓存节点 文档片段 事件解绑 性能优化
核心知识点速查
| 模块 | 必会 API | 一句话 |
|---|---|---|
| 节点 | nodeType、nodeName |
先分清节点类型再遍历 |
| 获取 | querySelector(All)、getElementById |
复杂用选择器,唯一 ID 用 ById |
| 遍历 | children、nextElementSibling |
优先 Element 系列,避开空白文本节点 |
| 属性 | .checked、dataset、getAttribute |
标准用属性对象,配置用 data-* |
| 样式 | classList、style、getComputedStyle |
改类名维护样式,测量用计算样式 |
| CRUD | createElement、append、remove、cloneNode |
先创建游离节点,最后一次性插入 DOM |
| 几何 | getBoundingClientRect、offsetWidth、scrollTop |
读几何触发回流,先写后读 |
| 加载 | DOMContentLoaded、defer、readyState |
defer 替代 DOMContentLoaded,CSS 放 head |
| 表单 | FormData、checkValidity、input 事件 |
novalidate + 自定义验证 = 最佳实践 |
| 事件 | addEventListener、事件委托、preventDefault |
事件委托减少监听器,preventDefault 阻止默认行为 |
| 性能 | transform/opacity、批量读写、RAF |
动画用合成属性,分离读写操作 |
| Event Loop | 微任务/宏任务、requestAnimationFrame |
连续写入自动批量,只避免读写交替 |
| 现代 API | IntersectionObserver、MutationObserver |
异步观察器替代轮询 |
| 安全 | textContent、CSP、转义 |
永不信任用户输入 |
案例与知识点对应
| 案例 | 涉及的 DOM 技能 |
|---|---|
| 购物车 / 全选反选 | querySelectorAll、checked、indeterminate、事件委托 |
| 电子时钟 | getElementById、textContent、定时更新 DOM |
| 选项卡 | dataset、classList、getElementById |
| 全屏滚动 | style.height、querySelectorAll(配合 BOM 滚动 API) |
| 属性 / 图片切换 | 内置属性 src、alt,dataset |
| 类名卡片 | classList 全套方法 |
| 事件委托 | closest、matches、事件冒泡 |
| 虚拟滚动 | 尺寸计算、动态渲染、性能优化 |
| 拖放 | DragEvent、dataTransfer、drop 事件 |
| 无限滚动 | IntersectionObserver、异步加载 |
| 组件开发 | Shadow DOM、Custom Elements、样式隔离 |
进阶学习路径
DOM 基础
节点/获取/遍历
CRUD 操作
几何坐标
事件处理
样式操作
事件委托
动画性能
加载生命周期
script 策略
表单 DOM
FormData
组件化
Web Components
安全防护
Event Loop
渲染机制
现代 API
Observer 族
工程化架构
学习建议
- 循序渐进:先掌握节点、获取、遍历、属性、样式基础,再学习事件、性能、安全
- 动手实践 :每个示例在浏览器中完整运行一遍,用开发者工具观察 DOM 与样式变化
- 图片资源 :图片类示例将资源放在同级
images/目录(如images/img001.jpg) - 性能意识 :新项目优先
classList+ CSS 类,避免滥用行内样式与频繁回流 - 安全第一 :永远不信任用户输入,
textContent优于innerHTML - 现代 API :优先使用
IntersectionObserver等异步 API,替代传统轮询 - 深入阅读 :查阅以下资料扩展知识
掌握以上内容,您将能够:
- ✅ 独立完成表单、列表、Tab、滚动页等常见前端 DOM 交互开发
- ✅ 使用事件委托优化大型列表性能
- ✅ 运用 IntersectionObserver 实现懒加载与无限滚动
- ✅ 理解浏览器渲染原理,编写高性能动画
- ✅ 开发安全的 DOM 操作代码,防范 XSS 攻击
- ✅ 使用 Shadow DOM 构建可复用的 Web Components
- ✅ 处理十万级数据渲染(虚拟滚动)
- ✅ 实现拖放、选项卡、全选联动等复杂交互
- ✅ 精确操控 DOM CRUD(创建、插入、删除、克隆)
- ✅ 利用 offset/client/scroll/getBoundingClientRect 实现精准几何布局
- ✅ 合理配置 script defer/async,消灭白屏阻塞
- ✅ 构建完整的表单验证方案(FormData + Constraint API)
- ✅ 深入理解 Event Loop,解释批量更新与 nextTick 的本质
参考资料
DOM 核心
样式与几何
- MDN - CSSOM / getComputedStyle
- MDN - Element.getBoundingClientRect()
- MDN - HTMLElement.offsetWidth
- MDN - Element.classList
CRUD 与遍历
- MDN - Document.createElement
- MDN - Element.insertAdjacentHTML
- MDN - DocumentFragment
- MDN - HTMLElement.dataset
事件与 Event Loop
- MDN - EventTarget.addEventListener
- MDN - Window.requestAnimationFrame
- MDN - 深入:微任务与 JavaScript 运行时环境
- HTML Living Standard - Event Loop
加载与性能
表单
现代 API 与安全