前言
- 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'); const snap = box.querySelectorAll('.child'); console.log(live.length, snap.length);
box.appendChild(Object.assign(document.createElement('p'), { className: 'child' }));
console.log(live.length, snap.length); 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; p1.parentElement; p1.nextElementSibling; p1.previousElementSibling; p1.children; p1.childNodes; p1.closest('.card'); p1.closest('#nope'); p1.matches('#box > .child'); box.contains(p1);
|
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 -> <b>安全</b> // 被转义 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.insertAdjacentHTML('afterbegin', '<b>in1</b>'); d.insertAdjacentHTML('beforeend', '<b>in2</b>'); d.insertAdjacentHTML('afterend', '<i>C</i>'); 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; box.dataset.userName; box.dataset.page = 3; delete box.dataset.id; 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'); box.classList.length; box.classList.remove('hot'); box.classList.toggle('cold'); box.classList.toggle('hot'); 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
| box.style.color; box.style.backgroundColor = 'rgb(1, 2, 3)';
getComputedStyle(box).color; getComputedStyle(box).fontSize;
box.style.setProperty('border-radius', '4px'); box.style.borderRadius;
box.style.cssText;
|
| 需求 |
做法 |
| 读「现在看起来是什么」 |
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
|
btn.onclick = () => console.log('A'); btn.onclick = null;
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();
el.addEventListener('wheel', fn, { passive: true }); el.addEventListener('click', fn, 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(); 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; } });
|
禁用右键 / 取消跳转 / 禁止选中
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('删除?'); const name = prompt('名字', '默认值'); 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) { 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'); 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);
|
- 存进去什么都会变字符串:
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); } }); mo.observe(target, { childList: true, subtree: false, attributes: false });
const io = new IntersectionObserver((entries) => { for (const e of entries) { if (e.isIntersecting) { loadLazyImage(e.target); io.unobserve(e.target); } } }, { rootMargin: '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。
参考资料