DOM 和 BOM

前言

  • DOM(Document Object Model,文档对象模型):浏览器把 HTML 解析成一颗节点树,JS 通过它读写文档结构、内容和属性,并响应事件。
  • BOM(Browser Object Model,浏览器对象模型):window、navigator、location、history、screen 这些「浏览器本身」的对象,与文档内容无关。
  • 本篇结论都在本机 Chrome 的探测页面上实测:节点数量、集合是动态还是静态、href 读出来是什么、innerHTML 里的 <script> 会不会执行、事件三阶段的顺序、stopPropagation 到底拦住了谁、localStorage 存数字读回什么类型……都跑过。生命周期那张表是单独一个「带 404 图片」的页面实测的。

DOM 是一棵节点树

浏览器解析 HTML 后得到的是节点树,不只是元素:文本是节点、注释是节点、标签之间的空白也是文本节点。

1
2
3
4
5
6
7
8
<div id="box" class="card hot" data-id="7" data-user-name="小李">
第一行文本
<!-- 这是注释节点 -->
<p id="p1" class="child">段落一</p>
<p id="p2" class="child hidden">段落二</p>
<span class="child">行内</span>
<ul id="list"><li>a</li><li>b</li></ul>
</div>

实测(对上面这段的 #box 取子节点):

1
2
3
4
5
6
7
8
9
box.childNodes.length        = 11     // 文本、注释、元素全算
box.children.length = 4 // 只算元素
box.firstChild.nodeType = 3 // 第一个是文本节点(标签后的换行+缩进)
box.firstChild.nodeName = "#text"
box.firstElementChild.id = "p1"
box.lastElementChild.tagName = "UL"
p1.nextSibling.nodeName = "#text"
p1.nextElementSibling.id = "p2"
p2.previousElementSibling.id = "p1"

节点类型(用 nodeType 判断):

nodeType 常量 nodeName 说明
1 Node.ELEMENT_NODE 大写标签名 元素,日常主要操作对象
3 Node.TEXT_NODE #text 文本,空白也算
8 Node.COMMENT_NODE #comment 注释
9 Node.DOCUMENT_NODE #document document 本身
11 Node.DOCUMENT_FRAGMENT_NODE #document-fragment DocumentFragment
  • 日常代码一律用 Element 系 API(children、firstElementChild、nextElementSibling),绕开空白文本节点。
  • 其它入口:document.documentElement(<html>)、document.head、document.body、document.title、document.readyState。

查找元素

API 返回值 动态? 说明
document.getElementById(id) 元素或 null — 最直接;id 必须唯一
document.getElementsByClassName(name) HTMLCollection 动态 没有 forEach,只能 for 或先 [...]
document.getElementsByTagName(tag) HTMLCollection 动态 传 '*' 取全部元素
document.getElementsByName(name) NodeList 动态 常用来取同名单选组(实测取到 1 个 name="u")
document.querySelector(sel) 第一个匹配元素或 null — 任意 CSS 选择器
document.querySelectorAll(sel) NodeList(静态快照) 静态 有 forEach,可展开
el.querySelector / querySelectorAll 同上 — 只在 el 子树里找
document.forms / form.elements 集合 — 实测 document.forms.length = 1、form.elements.length = 4

「动态 vs 静态」实测:

1
2
3
4
5
6
7
8
9
const live = box.getElementsByClassName('child');       // HTMLCollection,实时
const snap = box.querySelectorAll('.child'); // NodeList,快照
console.log(live.length, snap.length); // 3 3

box.appendChild(Object.assign(document.createElement('p'), { className: 'child' }));

console.log(live.length, snap.length); // 4 3 <- 只有 live 变了
console.log(live.constructor.name, snap.constructor.name);
console.log(Array.isArray(live), typeof live.forEach, typeof snap.forEach);
1
2
3
4
3 3
4 3
HTMLCollection NodeList
false undefined function
  • 「先收集再遍历、遍历中还要删元素」用 querySelectorAll(快照);想跟着 DOM 一起变才用 getElementsByClassName。
  • 需要 map / filter 就先转数组:[...els] 或 Array.from(els)。
  • 属性选择器同样好用:document.querySelectorAll('[data-user-name]') 实测命中 1 个,'#box p' 命中 3 个。

关系遍历与就近查找

1
2
3
4
5
6
7
8
9
10
p1.parentNode;               // DIV(任何节点都有)
p1.parentElement; // DIV(父元素是元素时才有)
p1.nextElementSibling; // 下一个兄弟「元素」
p1.previousElementSibling;
p1.children; // 自己的元素子节点
p1.childNodes; // 含文本/注释
p1.closest('.card'); // 向上找第一个匹配的祖先(含自己) -> 实测找到
p1.closest('#nope'); // -> null
p1.matches('#box > .child'); // 自己是否匹配该选择器 -> true
box.contains(p1); // 包含关系 -> true
  • closest() 是事件委托的标配:从 e.target 往上找真正的目标控件,比手写 while (el = el.parentElement) 干净。
  • closest 只看祖先链,与元素是否可见、是否被裁剪无关。

读与改内容

属性 读什么 写什么 注意
textContent 全部文本,含隐藏元素里的文字 替换成纯文本节点 安全,不解析 HTML
innerText 渲染后可见的文本 按纯文本替换(换行转 <br>) 触发重排,性能差
innerHTML 序列化出来的 HTML 字符串(序列化 = 把内存里的对象/树还原成一串可以传输或保存的文本) 按 HTML 解析 只用于可信内容,否则 XSS

实测差别:

1
2
3
4
5
6
$('p1').textContent                   -> "段落一"
$('box').textContent 里有隐藏的段落二? -> true
$('box').innerText 里有隐藏的段落二? -> false // display:none 的内容被跳过

div.textContent = '<b>安全</b>'; 读 div.innerHTML -> &lt;b&gt;安全&lt;/b&gt; // 被转义
div.innerHTML = '<script>…<\/script>'; -> 元素进了文档,但脚本没执行
  • 「innerHTML 里的 <script> 不执行」是规范行为(实测:新增了一个 script 元素,里面那句赋值没跑)。要动态执行脚本得显式 createElement('script') 再 appendChild。
  • 在四个位置插 HTML,用 insertAdjacentHTML(实测结果):
1
2
3
4
5
6
7
host.innerHTML = '<div>B</div>';
const d = host.firstElementChild;
d.insertAdjacentHTML('beforebegin', '<i>A</i>'); // d 之前
d.insertAdjacentHTML('afterbegin', '<b>in1</b>'); // d 内部最前
d.insertAdjacentHTML('beforeend', '<b>in2</b>'); // d 内部最后
d.insertAdjacentHTML('afterend', '<i>C</i>'); // d 之后
console.log(host.innerHTML);
1
<i>A</i><div><b>in1</b>B<b>in2</b></div><i>C</i>
  • 把用户输入拼进 innerHTML 等于自助 XSS。展示纯文本一律用 textContent。
  • innerHTML += ... 会把已有内容全部重建,绑好的事件和输入状态随之丢失——改用 insertAdjacentHTML 或 append。
  • document.write 在解析结束后调用会重写整个文档,现代代码里不要用。

属性与 dataset

HTML 属性(attribute)≠ DOM 属性(property)

实测:

1
2
3
4
5
6
7
8
9
link.href                        -> http://127.0.0.1:4099/relative/path?b=2#c   // 浏览器算出的绝对地址
link.getAttribute('href') -> /relative/path?b=2#c // 你写的原值

inp.setAttribute('value', 'attr 值'); 读 [inp.value, inp.getAttribute('value')] -> ['attr 值', 'attr 值']
inp.value = '用户输入'; 读 [inp.value, inp.getAttribute('value')] -> ['用户输入', 'attr 值']
inp.defaultValue -> attr 值 // 「初始值」永远不变

cb.checked = true; 读 [cb.checked, cb.defaultChecked] -> [true, false]
inp.removeAttribute('placeholder'); 读 [hasAttribute, inp.placeholder] -> [false, '']
  • 读用户当前输入用 property(el.value、el.checked、el.selectedIndex)。
  • 改元素特征(class、id、href、src、data-*)也优先用 property。
  • 只有写非标准属性时才 setAttribute。
  • default* 系列(defaultValue / defaultChecked)对应 HTML 里写的那个值,form.reset() 就是回到它们。
  • 只有 attribute 会被序列化进 outerHTML;直接 el.foo = 1 挂的 JS 属性不会出现在 HTML 里——要「写进 HTML 的数据」请用 data-*。

dataset:data-* 的读写口

1
<div id="box" data-id="7" data-user-name="小李"></div>
1
2
3
4
5
box.dataset.id;          // '7'    —— 永远是字符串,要数字自己转
box.dataset.userName; // '小李' —— 短横线自动转小驼峰
box.dataset.page = 3; // 写回 HTML:data-page="3"
delete box.dataset.id; // 删属性:hasAttribute('data-id') 变 false
console.log(JSON.stringify(box.dataset));
1
{"userName":"小李","page":"3"}
  • data-* 是「把数据挂在元素上」的规范做法(比 onclick="fn(7)" 好得多),读出来全是字符串。
  • 属性名只能用短横线:实测 setAttribute('data-myKey', 'x') 会被规范化成小写的 data-mykey,读回来是 dataset.mykey——想表达两个单词就写 data-my-key。

classList:操作类名的正确姿势

1
2
3
4
5
6
7
box.classList.contains('hot');          // true
box.classList.length; // 2(card hot)
box.classList.remove('hot'); // 一次可删多个:remove('hot', 'photo')
box.classList.toggle('cold'); // 没有就加,返回 true
box.classList.toggle('hot'); // 已经有了就删,返回 false
box.classList.replace('cold', 'warm');
box.className = 'card'; // 整体替换
  • 实测上面这段跑完后类名的轨迹:card hot →(toggle('cold') 加上 cold、toggle('hot') 删掉 hot)→ card cold →(replace 成 warm、再 remove('warm'))→ card。toggle 的返回值正好表示「这次是留还是删」。
  • 别用字符串拼 className,容易误伤含子串的名字('hot' 与 'photo')。

样式操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 1) 行内样式:el.style 只管标签上那一份 style
box.style.color; // '' —— 实测:CSS 文件里的值它读不到
box.style.backgroundColor = 'rgb(1, 2, 3)'; // 立刻写进 style 属性

// 2) 计算样式:最终生效值(含继承、CSS 规则、浏览器默认)
getComputedStyle(box).color; // rgb(10, 20, 30) —— 实测
getComputedStyle(box).fontSize; // 16px

// 3) 驼峰与短横线两种写法都认
box.style.setProperty('border-radius', '4px');
box.style.borderRadius; // '4px' —— 实测两种读法同值

// 4) 整串读改
box.style.cssText; // 'background-color: rgb(1, 2, 3); border-radius: 4px;'
需求 做法
读「现在看起来是什么」 getComputedStyle(el).xxx
改少量、由 JS 算出来的值 el.style.xxx = ... / el.style.setProperty(...)
改成组的主题态、状态样式 切换类名,样式留在 CSS 里
主题色、尺寸标度 CSS 自定义属性:document.documentElement.style.setProperty('--brand', '#06c')
动画 transition / animation + 类名切换
  • el.style 是声明值:CSS 文件、<style> 里的规则一概看不见(实测 box.style.color === '',而 getComputedStyle(box).color === 'rgb(10, 20, 30)')。
  • getComputedStyle 会触发样式/布局计算,别在紧循环里反复读同一个值。
  • 带 !important 的规则 el.style.xxx = ... 压不过,需要先 removeProperty 或改用类名。

创建、插入、替换、删除

1
2
3
4
5
6
7
8
9
const el = document.createElement('div');
el.className = 'card';
el.textContent = '内容'; // 安全
el.innerHTML = '<b>富文本</b>'; // 只在内容可信时

const frag = document.createDocumentFragment();
for (let i = 0; i < 3; i++) {
frag.append(document.createElement('li'));
}
操作 API 实测结果
末尾追加(可混字符串与节点) parent.append('文本', el) 一次产生 2 个子节点
开头追加 parent.prepend(...) 文本变成 'ab'
相对兄弟插入 el.before(n) / el.after(n) a.after(b) 后 a.nextElementSibling 是 B
指定位置插入 parent.insertBefore(new, ref)(ref 传 null 等价 append) —
批量插入不反复重排 parent.append(fragment) ul 子项数 2 → 5 一次完成
替换全部子节点 parent.replaceChildren(a, b) 传 1 个就剩 1 个
浅拷贝 el.cloneNode(false) 子元素数 0
深拷贝 el.cloneNode(true) 子元素数 5,id 也照抄(实测克隆体仍带 p1,会撞 id)
删除 el.remove() 删后 document.body.contains(el) 为 false
替换 old.replaceWith(fresh) / parent.replaceChild(new, old) —
  • 循环里逐个 append 会多次触发布局,攒进 DocumentFragment 一次插入更划算(片段本身不会留在 DOM 里)。
  • cloneNode(true) 不复制 addEventListener 绑的监听器,但会复制 id 和内联 on* 属性,克隆后记得改掉 id。
  • 清空容器:parent.replaceChildren()(不传参)最干净,等价于老写法 innerHTML = ''。

事件:绑定与三个阶段

三种绑定方式

1
2
3
4
5
6
7
8
9
10
// 1) HTML 属性:能不用就不用(难维护、和 CSP 冲突)
// <button id="btn" onclick="doThing()">按钮</button>

// 2) on 属性:同一类型只能有一个,后来者覆盖前者
btn.onclick = () => console.log('A');
btn.onclick = null; // 解绑

// 3) addEventListener:可挂多个、能选阶段、能带选项(首选)
btn.addEventListener('click', handler);
btn.removeEventListener('click', handler); // 必须是同一个函数引用

removeEventListener 摘不掉监听器,九成因为传的是新写的匿名函数——每次求值都是不同的函数对象。

选项对象

1
2
3
4
5
6
7
8
el.addEventListener('click', fn, { once: true });          // 触发一次后自动解绑

const ac = new AbortController();
el.addEventListener('click', fn, { signal: ac.signal }); // 统一批量解绑
ac.abort(); // 摘掉所有带这个 signal 的监听

el.addEventListener('wheel', fn, { passive: true }); // 承诺不 preventDefault,滚动更跟手
el.addEventListener('click', fn, true); // 第三个参数 true = 捕获阶段

实测 once + AbortController:给同一按钮挂「一次性的」和「带 signal 的」两个监听,两次点击后计数为 3(第一次两个都执行,第二次只剩 signal 那个);abort() 后再点一次,计数仍是 3。

事件流:捕获 → 目标 → 冒泡

1
2
3
4
5
6
7
8
9
10
const outer = document.createElement('div');
const inner = document.createElement('span');
outer.append(inner);
document.body.append(outer);

outer.addEventListener('click', (e) => log('外层'), true); // 捕获
outer.addEventListener('click', (e) => log('外层'), false); // 冒泡
inner.addEventListener('click', () => log('内层')); // 目标

inner.dispatchEvent(new MouseEvent('click', { bubbles: true }));

实测输出(括号里是 eventPhase 判断出的阶段):

1
2
3
click:DIV(捕获)
click:SPAN(目标)
click:DIV(冒泡)
  • eventPhase:1 捕获、2 目标、3 冒泡。业务代码基本只关心 2 和 3。

阻止传播 / 阻止默认行为

方法 作用 实测
e.stopPropagation() 不再向外冒(同一元素上的其它监听仍会执行) 内层点击后,外层监听没跑,日志只剩 inner
e.stopImmediatePropagation() 外层不跑,同元素后登记的监听也不跑 日志只剩 第一个
e.preventDefault() 取消默认行为(跳转、提交、右键菜单、选中…) e.defaultPrevented 为 true;submit 被拦下时不会发请求;contextmenu 被禁
1
2
3
4
5
6
7
8
9
form.addEventListener('submit', (e) => {
e.preventDefault(); // 拦住原生提交(默认会真的发请求并整页跳转)
fetch(form.action, { method: 'POST', body: new FormData(form) });
});

a.addEventListener('click', (e) => {
if (e.defaultPrevented) { return; } // 上层已经处理过,别再跳
e.preventDefault();
});
  • 只有 cancelable: true 的事件拦得住默认行为。
  • e.cancelBubble = true、on 属性里 return false 都是老古董,别再用。

target 与 currentTarget,事件委托

1
2
3
4
5
ul.addEventListener('click', (e) => {
const li = e.target.closest('li'); // 真实想点中的那一层
if (!li) { return; }
console.log(li.dataset.v);
});

实测点击第二个 li:

1
委托拿到 2 | target=LI currentTarget=UL
  • e.target 是事件最初命中的元素,e.currentTarget 是当前执行监听器的元素(等价于非箭头函数里的 this)。
  • 点 <li><b>粗体</b></li> 里的 b 时 target 是 B,所以要 closest('li')。
  • 委托的三个好处:监听器数量恒定、动态增删元素无需补绑/解绑、性能更好。

事件分类速查

页面生命周期(实测时序)

一个内含 404 图片的页面,按顺序打印:

1
2
3
4
5
同步脚本执行
注册完监听,此刻 readyState=loading
DOMContentLoaded (doc.readyState=interactive)
load (doc.readyState=complete)
img.complete=true naturalWidth=0
时机 事件 / 状态 用法
文档解析完(不等图片) DOMContentLoaded,readyState === 'interactive' 大多数脚本的起点;defer / type="module" 天然在此之后
全部资源就绪 window 的 load,readyState === 'complete' 需要图片真实尺寸时(实测 404 图 complete=true 而 naturalWidth=0)
切后台/最小化 visibilitychange(document.visibilityState) 暂停视频、降帧、暂停轮询
即将离开 pagehide(不要用 unload) 收尾上报配合 navigator.sendBeacon(url, data)

焦点

事件 冒泡? 备注
focus / blur 不冒泡 想在父层统一处理,用下面两个
focusin / focusout 冒泡 与 focus/blur 成对出现
:focus-visible(CSS 伪类) — 只在键盘导航时给焦点样式,避免点击时也出现描边
  • 实测注意:探测页不在系统前台时,input.focus() 并不会真的移走焦点(document.activeElement 仍是 BODY)——焦点类行为要在聚焦的窗口里验。
  • 「点空白处关闭下拉」的正确写法是 document 上的 click + if (!box.contains(e.target)) close(),绑 blur 会在点按钮时先触发导致误关。

鼠标 / 指针

事件 时机 备注
pointerover/out/enter/leave/down/up/move 指针统一事件(鼠标/触摸/手写笔) 现代首选,实测 PointerEvent 存在
mouseover/out 会冒泡(子元素进出也会触发) 常用于委托
mouseenter/leave 不冒泡,只在真正进出时触发 常用于 hover 效果
mousedown/up/click/dblclick 按下/松开/点击/双击 实测顺序 mousedown → mouseup → click → dblclick
contextmenu 右键或菜单键 可 preventDefault 做自定义菜单
wheel 滚轮 想 preventDefault 就不能写 passive: true
drag* 见「实战片段」 —

坐标含义(实测一个 left:10px; top:10px 的盒子,点击其内部偏移 (20, 15) 处):

属性 相对谁
clientX / clientY 视口左上角
pageX / pageY 整个文档左上角(= client + 滚动量)
screenX / screenY 显示器屏幕
offsetX / offsetY 事件目标元素的 padding 盒左上角(做拖拽用它)
movementX / movementY 相对上一个 move 的位移(指针锁定场景)
  • e.button:0 左、1 中、2 右;e.buttons 是按住状态的位掩码。
  • e.relatedTarget:over/out 里「从哪个元素来 / 到哪个元素去」。

键盘

1
2
3
4
5
6
7
8
document.addEventListener('keydown', (e) => {
if (e.isComposing) { return; } // 中文输入法候选中,别抢按键
if (e.key === 'Enter') { submit(); }
if (e.ctrlKey && e.key.toLowerCase() === 's') {
e.preventDefault(); // 拦下浏览器的「保存网页」
save();
}
});

实测(用 new KeyboardEvent(...) 构造后 dispatch 读回):

1
2
3
keydown key=Enter code=Enter ctrl=true repeat=false
keydown key=A code=KeyA ctrl=false repeat=true
keyup
  • key:字符或名称('a'、'Enter'、'Escape'、'ArrowUp'、空格是 ' ')。
  • code:物理位置('KeyA'、'Digit1'、'Space'),换键盘布局不变——游戏按键映射用 code。
  • 修饰键:ctrlKey / shiftKey / altKey / metaKey(Mac 的 ⌘)。
  • repeat 表示长按连续触发;输入法相关还有 compositionstart/update/end。
  • keyCode / charCode / which 已废弃。

表单

事件 时机
input 值一改变就触发(打字过程中每次都触发)
change 失焦且值确实变了才触发(checkbox/radio/select 立即触发)
beforeinput 值改变之前,可 preventDefault(做输入过滤、字数上限)
submit 提交前,可拦截
invalid 校验失败时,在对应控件上触发(不冒泡,捕获阶段能收到)
reset 点重置
select 文本被选中
  • 实测:依次 dispatch 后按登记顺序打印 input、change;select 当前值/序号/文本是 y 1 Y。
  • 实时提示用 input(配防抖),「填完再提醒」用 change。

实战片段

拖拽(HTML5 Drag and Drop)

元素要有 draggable="true"(img、a 默认可拖)。事件序列:

1
dragstart -> drag -> dragenter -> dragover -> dragleave / drop -> dragend

说明:这一段是规范给出的顺序,本机无法用真鼠标实测;其余部分都在 Chrome 跑过。

1
2
<div id="src" draggable="true">拖我</div>
<div id="dst" style="width:200px;height:120px;border:2px dashed #999">放这里</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const src = document.getElementById('src');
const dst = document.getElementById('dst');

src.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('text/plain', src.id); // 只能带字符串
e.dataTransfer.effectAllowed = 'move';
});

dst.addEventListener('dragover', (e) => {
e.preventDefault(); // 关键:不拦就不允许放置,drop 永不触发
e.dataTransfer.dropEffect = 'move';
});

dst.addEventListener('drop', (e) => {
e.preventDefault();
dst.append(document.getElementById(e.dataTransfer.getData('text/plain')));
});

src.addEventListener('dragend', () => console.log('拖拽结束(无论成功与否都会触发)'));

想做自由拖动(跟随鼠标),用指针事件更可控:pointerdown 记下偏移 → pointermove 改 transform: translate() → pointerup/pointercancel 收尾,配合 setPointerCapture 防止指针滑出元素就断。

选项卡(委托 + closest + toggle)

1
2
3
4
5
6
<div class="tabs">
<button class="tab active" data-pane="p1">标签一</button>
<button class="tab" data-pane="p2">标签二</button>
<div class="pane" id="p1">内容一</div>
<div class="pane" id="p2" hidden>内容二</div>
</div>
1
2
3
4
5
6
7
8
9
10
11
document.querySelector('.tabs').addEventListener('click', (e) => {
const btn = e.target.closest('.tab');
if (!btn) { return; }
for (const t of btn.parentElement.querySelectorAll('.tab')) {
t.classList.toggle('active', t === btn);
t.setAttribute('aria-selected', String(t === btn));
}
for (const p of document.querySelectorAll('.pane')) {
p.hidden = p.id !== btn.dataset.pane; // hidden 属性比 display:none 更语义化
}
});

禁用右键 / 取消跳转 / 禁止选中

1
2
3
4
5
document.addEventListener('contextmenu', (e) => e.preventDefault());
document.querySelector('a[data-confirm]')
.addEventListener('click', (e) => { if (!confirm('确定离开?')) { e.preventDefault(); } });
document.addEventListener('selectstart', (e) => e.preventDefault()); // 禁选中
document.addEventListener('dragstart', (e) => e.preventDefault()); // 禁图片被拖走

CSS 里也能做:user-select: none;、-webkit-user-drag: none;。

这类限制全部可以绕过(禁用 JS、直接看源码即可),实际只能防误操作,别指望防盗,而且常常伤害正常用户。

弹窗类 API

1
2
3
4
alert('提示');                          // 阻塞式,只有「确定」
const ok = confirm('删除?'); // true / false
const name = prompt('名字', '默认值'); // 字符串或 null(取消时)
window.open('https://example.com', '_blank', 'width=600,height=400');

实测 typeof alert / confirm / prompt / open 全是 function。三个模态框会冻结主线程,只适合调试或极轻确认;window.open 在非用户手势时会被弹窗拦截器拦下。

定时器与逐帧动画

1
2
3
4
5
6
7
8
9
10
11
const t = setTimeout(() => console.log('200ms 后'), 200);
clearTimeout(t);

const iv = setInterval(() => console.log('每 100ms'), 100);
clearInterval(iv);

function frame(now) { // rAF 回调参数是高精度时间戳
// 每帧一次,页面隐藏时自动暂停
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

实测:浏览器里 setTimeout 返回的 id 是 number(Node 里是对象,跨环境代码别对它做算术),requestAnimationFrame 存在,MutationObserver / IntersectionObserver / ResizeObserver 三个 API 都存在。

  • setTimeout 的延时是「至少这么久」:要等当前同步任务与微任务清空,主线程忙就会延后。
  • setInterval 不等上次回调(你交给它、由它在到点时反过来调用的那个函数)结束,重任务会堆积;稳妥做法是用 setTimeout 自递归「跑完再排下一次」。
  • 动画一律用 requestAnimationFrame:与刷新率同步、后台标签页自动停、省电。
  • 组件卸载/页面离开前记得 clearTimeout、clearInterval、AbortController.abort()。

BOM:window、navigator、location、history

window

既是全局对象,也代表「这个窗口/标签页」。实测(视口 888×858,devicePixelRatio 1.25):

属性 / 方法 说明
innerWidth / innerHeight 视口尺寸(实测 888 / 858),做响应式要的是它
outerWidth / outerHeight 含浏览器界面的窗口尺寸;语义在各平台混乱(实测在自动化窗口里是 0),别依赖
devicePixelRatio CSS 像素与物理像素之比(实测 1.25),canvas 要乘它才清晰
scrollX / pageXOffset、scrollY / pageYOffset 滚动量;实测 window.scrollY === window.pageYOffset === document.documentElement.scrollTop
scrollTo({ top, behavior: 'smooth' }) / scrollBy 滚动;el.scrollIntoView({ block: 'center' }) 滚到元素
matchMedia('(max-width: 600px)').matches 在 JS 里问媒体查询
getComputedStyle、requestAnimationFrame、alert 都挂在 window 上(全局函数就是它的属性)
addEventListener('resize' / 'scroll' / 'storage' / 'popstate' / 'pagehide', …) 窗口级事件
  • var 声明的全局变量会变成 window 的属性,所以「随便挂全局」容易造成命名冲突;模块作用域天然隔离。
  • resize / scroll 触发极频繁,回调要做防抖或节流;只关心某个元素尺寸变化时用 ResizeObserver 更准。

screen 与 navigator

对象 常用属性 实测值
screen width、height、availWidth/availHeight、colorDepth 2048×1152(CSS 像素,即 2560×1440 除以 dpr 1.25)、colorDepth 24
navigator platform、language、languages.length、onLine、cookieEnabled、maxTouchPoints、hardwareConcurrency、clipboard Win32、zh-CN、3、true、true、10、20、object
  • screen 是显示器尺寸,和窗口无关;响应式要用 innerWidth 或 CSS 视口单位。
  • navigator.userAgent 嗅探设备类型已属不可靠(可伪造,Chrome 正在缩减 UA)。判能力请用特性检测:if ('IntersectionObserver' in window)、if ('showPicker' in HTMLInputElement.prototype)。
  • navigator.clipboard 是异步 API,需要 HTTPS 与用户手势,读写都要 await。
  • 离开页面时上报数据用 navigator.sendBeacon(url, data),它不会被「页面正在卸载」打断。

location

属性 / 方法 实测 说明
href http://127.0.0.1:4099/probe_dom.html?v=2 完整地址;写它 = 导航
origin http://127.0.0.1:4099 协议 + 主机 + 端口
protocol / host / hostname / port http: / 127.0.0.1:4099 / 127.0.0.1 / '' host 带端口,hostname 不带
pathname /probe_dom.html 路径
search / hash ?v=2 / '' 查询串 / 片段
searchParams — URLSearchParams,读参数首选
assign(url) / replace(url) / reload() 都是 function replace 不留历史(登录跳转常用)
1
2
3
4
const v = new URLSearchParams(location.search).get('v');     // '2'
const u = new URL('/api/list', location.origin);
u.searchParams.set('page', 2);
location.assign(u); // 跳转(留历史)

history

API 作用
history.length 会话历史条数(实测本机为 31,会被自动化操作影响,别拿它做业务判断)
history.state 最近一次 pushState 存入的对象(初始 null)
pushState(state, title, url) 改地址栏并新增一条历史,不发请求
replaceState(state, title, url) 同上但不新增历史
back() / forward() / go(n) 前进后退
popstate 事件 用户前进/后退时触发
  • 单页应用的路由 = pushState + popstate(Chromium 上还有更新的 Navigation API)。
  • 只能操作同源地址,跨域直接抛 SecurityError。

本地存储

方案 生命周期 容量 类型 异步? 随请求发送
localStorage 手动清除 约 5MB 字符串 同步 否
sessionStorage 标签页关闭 约 5MB 字符串 同步 否
Cookie 由过期时间决定 约 4KB 字符串 手动解析 是(每次请求都带)
IndexedDB 手动清除 大 结构化(对象、Blob) 异步 否
Cache Storage / OPFS 手动清除 大 响应 / 文件 异步 否

实测 localStorage:

1
2
3
4
5
6
7
8
localStorage.setItem('probeNum', 123);      // 写入数字
localStorage.probeStr = '中文'; // 点号写法也行
console.log(localStorage.getItem('probeNum'), typeof localStorage.getItem('probeNum'));
console.log(localStorage.probeStr, localStorage.length);
localStorage.removeItem('probeNum');
console.log(localStorage.getItem('probeNum'));
localStorage.clear();
console.log(localStorage.length);
1
2
3
4
123 string
中文 2
null
0
  • 存进去什么都会变字符串:123 读回是 '123'。存对象必须 JSON.stringify 存、JSON.parse 取。
  • 同步 API,写大数据会卡主线程;海量或二进制数据用 IndexedDB。
  • 无痕模式可能直接抛 QuotaExceededError,要 try/catch。
  • 存 token 的安全提醒:localStorage 挡不住 XSS;敏感会话凭据用 HttpOnly + Secure + SameSite 的 Cookie。
  • 跨标签页同步:window.addEventListener('storage', e => …)(注意它只在其它标签页触发)。

观察器(用它们替代轮询)

观察器 观察什么 典型用途
MutationObserver DOM 结构/属性变化 监听第三方组件改动、内容注入后处理
IntersectionObserver 元素与视口的交叉状态 图片懒加载、曝光埋点、无限滚动、吸顶判断
ResizeObserver 元素自身尺寸变化 图表自适应、组件内响应式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const mo = new MutationObserver((mutations) => {
for (const m of mutations) {
console.log(m.type, m.addedNodes.length); // 实测打印 childList 1
}
});
mo.observe(target, { childList: true, subtree: false, attributes: false });
// 用完必须 mo.disconnect()

const io = new IntersectionObserver((entries) => {
for (const e of entries) {
if (e.isIntersecting) {
loadLazyImage(e.target);
io.unobserve(e.target);
}
}
}, { rootMargin: '200px' }); // 提前 200px 就开始加载
  • 实测 MutationObserver 的回调是异步(微任务)执行的:observe 后立刻改 DOM,回调要到当前同步代码跑完才触发。
  • IntersectionObserver 的 isIntersecting + intersectionRatio 足够做「进入视口」判断,不必再监听 scroll 手算坐标。

常见坑速查

  • Cannot read properties of null (reading 'addEventListener') → 脚本比元素先执行。<script> 放 </body> 前、加 defer,或等 DOMContentLoaded。
  • innerHTML += ... 之后事件全丢 → 它重建了整棵子树。改用 insertAdjacentHTML / append。
  • 遍历中集合自己变长 → getElementsByClassName 是动态集合;换 querySelectorAll。
  • removeEventListener 摘不掉 → 传的不是同一个函数引用(匿名函数每次都是新的)。
  • 点内层元素拿不到想要的层 → 用 e.target.closest(选择器)。
  • 拖放完全不生效 → dragover 里漏了 e.preventDefault()。
  • 中文输入法打字时事件乱触发 → 判断 e.isComposing,或等 compositionend。
  • 表单提交后整页刷新 → submit 监听器漏了 e.preventDefault()。
  • localStorage 存对象取回是字符串 → 必须 JSON.stringify / JSON.parse 成对使用。
  • 动画用 setInterval 改 left/top 卡顿 → 改 requestAnimationFrame + transform。
  • scroll / resize 里做重活导致掉帧 → 防抖/节流,回调里只做最小计算。
  • focus 委托失败 → 它不冒泡,要用 focusin / focusout。
  • canvas 在高分屏模糊 → 尺寸要乘 devicePixelRatio,并监听其变化重设。
  • 跨标签页状态不同步 → 监听 storage 事件,或用 BroadcastChannel。
  • window.outerWidth 拿到 0 / 奇怪值 → 该 API 语义不稳定,改用 innerWidth。

练习

  • 给《HTML入门》里的报名表接上 JS:用户名用 input 实时校验长度,邮箱用 change 校验格式,提交时 preventDefault,用 FormData + fetch 发到本地服务,再把结果显示出来。
  • 用事件委托做一个待办清单:一个 <ul> 只挂一个点击监听,支持新增(createElement + append)、完成(classList.toggle)、删除(remove()),并用 localStorage 持久化。
  • 写一个防抖搜索框(停止输入 300ms 后才请求),再用 console.count 对比加与不加防抖时的调用次数。
  • 用 IntersectionObserver 给页面图片做懒加载,并与 loading="lazy" 的效果对比。
  • 做「回到顶部」按钮:滚动超过 500px 显示,点击 window.scrollTo({ top: 0, behavior: 'smooth' })。
  • 用 pushState + popstate 做一个不改地址栏就切换内容的三页小 SPA。

参考资料