聊到JavaScript里的Document对象,很多人的第一反应还是getElementById、querySelector这些高频方法,毕竟业务代码天天写。但你要是追问一句“Document对象身上有哪些常见属性”,能脱口而出的往往只剩下title和cookie。这个现象我在技术面试里见得特别多,代码评审里也经常看到——大家操作DOM很熟练,但对“文档自身描述信息”这一层属性体系,几乎是一片空白。
其实Document对象的属性,可以理解成浏览器挂在页面上的实时体检报告:页面骨架长什么样、当前处于什么加载阶段、来源地址是谁、字符编码是什么、焦点落在哪个元素上,这些都藏在属性里。方法是我们主动调用的行为,属性则是浏览器持续维护的状态,两者配合才是完整的DOM能力。
这篇文章不打算按文档API逐个背书,而是把Document对象里常见属性按“它到底描述了什么”重新分组,讲清楚每个属性背后的原理、使用场景和典型坑。适合正在补前端基础的初学者,也适合想系统性查漏补缺的进阶开发者。
1. 先建立全局认知:属性与方法的分工,以及一张属性分类地图
1.1 为什么属性常常被忽略
方法是被“调用”的,出了问题直接复制报错信息去搜,很容易找到答案;属性则是“持续变化”的状态,不出问题的时候你根本想不起它的存在。这有点像医院体检:方法是你主动去做的检查项目,属性是仪器的实时读数,平时没人盯着看,但真到诊断的时候就全靠这些读数了。
前端框架里其实大量读取Document对象的属性来组织渲染流程。比如React的合成事件系统、Vue的挂载逻辑,背后都会用document.readyState判断脚本执行时机。只是框架把这些封装掉了,业务代码里很少直接触碰,导致很多开发者对这些属性的认知停留在“好像见过”的层面。属性不是不重要,只是被框架消化掉了。
1.2 Document对象的属性分类地图
为了避免一上来就陷入细节,我先给你一张分类地图。它是我多次在项目里查问题时的检索路径:
| 分类 | 代表属性 | 你能拿它做什么 |
|---|---|---|
| 骨架结构 | documentElement、body、head、doctype、scrollingElement | 定位根节点、拿滚动容器 |
| 元素集合 | forms、images、links、scripts、styleSheets | 不依赖选择器快速收集某类元素 |
| 文档状态 | readyState、hidden、visibilityState | 判断加载进度、页面可见性 |
| 来源身份 | URL、location、domain、referrer、baseURI | 读取当前地址、来源、基准URL |
| 环境编码 | characterSet、compatMode、contentType | 排查乱码、判断渲染模式 |
| 运行焦点 | activeElement、currentScript | 焦点管理、定位当前脚本 |
| 可写接口 | title、cookie、designMode | 改标题、读写Cookie、全文档编辑 |
注意,很多属性是只读的,真正能放心写的只有title、cookie、designMode等少数几个,domain虽然老版本可写,但现代浏览器已经把这个口子越收越紧。先有这张全局视图,后面遇到实际问题就能快速映射到对应的属性,而不是拿着选择器到处扫。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 页面骨架三件套:documentElement、body、head的实际关系与时序坑
2.1 documentElement才是真正的根
HTML文档最外层的根元素是<html>,document.documentElement拿到的就是它;而document.body和document.head只是documentElement下的两个子节点。说起来很简单,但很多人默认把body当成根,做全局配置时全往body上挂。
这里有一个很实际的区别:document.documentElement在文档解析出根元素后几乎不可能为null,而document.body在解析到body之前访问会返回null。所以如果你想在脚本最早阶段读取全局容器的信息,优先用documentElement,不要在脚本头部直接document.body.xxx,大概率踩空。
2.2 获取时机:为什么head里的document.body是null
浏览器解析页面是从上往下逐行解析的,如果脚本放在head里且没用defer、async,执行时解析器还没走到body,此时document.body自然是null。我见过不少新手在这写初始化逻辑,结果页面上的脚本报了一堆TypeError。
正确处理方式有两种。一种是把脚本放到body末尾,这是最土但最可靠的方式;另一种是保留在head里但注册监听:
javascript复制if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () {
console.log(document.body);
});
} else {
// DOM已被解析完,直接执行
console.log(document.body);
}
这里用到的readyState就是后面要细说的生命周期属性,提前感受一下它的用法。
2.3 视口高度到底用documentElement还是body
每次涉及“页面高度”“视口高度”,总会有人纠结到底读document.documentElement.clientHeight还是document.body.clientHeight。标准模式下,document.documentElement.clientHeight返回视口高度,也就是当前窗口里能看到的内容区域高度;document.body.clientHeight返回的是body元素被内容撑开后的实际高度,文档内容多长它就有多高。
但在怪异模式(没有正确doctype时)下,这两个值的语义可能互相颠倒。因此我的建议是:视口高度优先用window.innerHeight,页面内容总高度用document.documentElement.scrollHeight,同时保证页面带上<!DOCTYPE html>,别让自己掉进怪异模式的坑。
2.4 scrollingElement:滚动容器的兼容配方
移动端Web的历史遗留问题之一,就是html和body谁在滚动在不同浏览器里表现不一致。document.scrollingElement就是为了统一这个判断而生的:标准模式下返回documentElement,怪异模式下返回body。
我在多端项目里比较稳的写法是:
javascript复制const scroller = document.scrollingElement || document.documentElement;
console.log(scroller.scrollHeight, scroller.clientHeight);
不要自己去猜当前是html在滚还是body在滚,把判断交给scrollingElement,它在Chrome、Firefox、Safari里都支持得不错。
3. 集合类属性:forms、images、links、scripts与活动集合的性能陷阱
3.1 它们返回的不是普通数组
document.forms、document.images、document.links、document.scripts、document.embeds这些属性,返回的大多是HTMLCollection,也就是一个“活”的元素集合。HTMLCollection有length属性,也支持下标访问,甚至有namedItem方法,但它不是数组,不能用forEach。
还有一点容易忽略:document.styleSheets返回的是StyleSheetList,也不是数组。很多人在代码里对document.styleSheets直接调用forEach,在部分运行环境里会报错或静默失败,需要先用Array.from转一次才能放心遍历。
3.2 活集合的索引漂移和name/id陷阱
活集合的意思是:集合内容会跟着DOM树实时变化。你删掉页面里一张图片,document.images的length和顺序立刻更新。这带来一个常见的遍历坑——正序遍历然后删除元素时,索引会漂移。
很多做批量删除的代码会这样写:
javascript复制// 不推荐:删除过程中索引会漂移,可能漏删
for (let i = 0; i < document.images.length; i++) {
document.images[i].remove();
}
正确的姿势是倒序遍历,或者先把集合转成数组再遍历:
javascript复制// 推荐:先把快照取出来再操作
Array.from(document.images).forEach(function (img) {
img.remove();
});
name/id索引也有陷阱。document.forms.login这种按name访问的写法,看起来简洁,但如果页面里有多个同名的form或控件,它可能返回HTMLCollection而不是单个元素,不同浏览器的行为还不太一致。稳妥的方式是直接用document.forms.namedItem('login'),并判断返回结果到底是元素还是集合。
3.3 遍历性能与转换姿势
活集合每次读取length、每次下标访问,都会向浏览器重新查询一次最新状态。在循环里反复读document.images.length,等于每轮循环都触发一次文档查询,性能损耗在低频代码里无所谓,但放在滚动事件里就是另一回事了。
我个人的习惯是:任何超过一次访问的活集合,都先缓存成局部变量;需要频繁遍历或者循环体里有DOM操作的,直接转成普通数组作为快照。转数组的姿势有好几种:
javascript复制// 三种方式都行
const arr1 = Array.from(document.images);
const arr2 = [...document.images];
const arr3 = Array.prototype.slice.call(document.images);
注意[...]展开方式对迭代器对象没问题,但有些类数组对象不是可迭代的,所以最稳妥还是Array.from。
3.4 业务里最常见的三个用途
第一是图片统计和懒加载观察。要拿到页面所有图片直接document.images,不用写一个document.querySelectorAll('img'),而且这个集合天然覆盖了动态添加的图片,因为是活的。
第二是表单序列化。做老式表单提交或埋点采集时,document.forms可以帮你快速定位整个页面里的表单,再配合form.elements收集控件值。
第三是外链信息整理。document.links返回的是包含href属性的a元素和area元素,不等于所有a标签;document.anchors返回的则是带name属性的a标签。统计页面外链、判断链接数量时,用这些集合属性比写复杂选择器更直白。
4. readyState、visibilityState与hidden:页面生命周期状态机
4.1 readyState的三个阶段与事件顺序
document.readyState是理解页面加载进度的关键,它的值有三个阶段:
| 取值 | 含义 | 关联事件 |
|---|---|---|
| loading | 文档还在解析中 | 尚无 |
| interactive | 解析完成,DOM可访问 | 随后异步触发DOMContentLoaded |
| complete | 文档和所有子资源加载完成 | load事件已触发或即将触发 |
很多初始化函数会重复绑定,比如同一个页面的多个脚本都在监听DOMContentLoaded,或者脚本已经跑到DOMContentLoaded之后还在绑定事件,导致事件永远等不到。这时候可以直接检查readyState:
javascript复制function init() {
// 真正的初始化逻辑
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
这种写法比无脑挂DOMContentLoaded更稳妥,因为它能覆盖“脚本在页面完全加载后才执行”的场景。
4.2 visibilityState和hidden:页面可见性的双胞胎
document.hidden是一个老牌的布尔值属性,表示页面是否被隐藏;document.visibilityState则更精细,可能返回visible、hidden或prerender(部分浏览器)。日常业务里判断页面是否可见,直接用document.hidden就够了;但如果你需要区分“隐藏前一瞬间”和“正在预渲染”,就得用visibilityState。
和这个状态配合的是visibilitychange事件。它在标签页切换、窗口最小化、移动端切到后台时触发,是统计页面活跃度的核心事件。要注意的是,事件回调里document.visibilityState已经是变化后的新值,可以直接读取判断用户是切走还是切回。
4.3 实战:用visibilitychange上报页面停留时长
这里给一个真实的埋点思路。统计用户在页面的停留时长,不能只在beforeunload里记一个时间差,因为用户切后台再回来的情况会漏算。我在项目里的处理方式是把“可见时间段”累加起来:
javascript复制let visibleStart = Date.now();
let totalVisible = 0;
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'hidden') {
// 切走,累加这一段可见时间
totalVisible += Date.now() - visibleStart;
} else if (document.visibilityState === 'visible') {
// 切回来,重置开始时间
visibleStart = Date.now();
}
});
window.addEventListener('beforeunload', function () {
totalVisible += Date.now() - visibleStart;
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/time', JSON.stringify({ duration: totalVisible }));
}
});
这个方案在移动端尤其重要,因为用户经常切后台,不处理这种场景,停留时长数据会虚高。别忘了把sendBeacon用上,beforeunload里发同步请求经常被浏览器打断。
5. 身份四件套:URL、referrer、domain、baseURI告诉我们的信息
5.1 document.URL与location.href:一个只读一个可写
document.URL返回当前文档地址的只读字符串,而location.href既可读也可写。页面发生跳转后,document.URL会变成新地址。有一个和它容易搞混的是document.documentURI,在HTML文档里两者基本一样,但在XML文档里documentURI仍然有效,URL可能不适用于某些XML场景。对我们日常前端来说,熟悉document.URL是只读的就够了。
需要跳转时别去赋值document.URL,它是只读的,赋值会报错或无效;要跳转就用location.href = 'xxx',或者location.replace('xxx')避免产生历史记录。
5.2 referrer:访客从哪里来,以及隐私边界
document.referrer返回上一个页面的完整URL,没有来源或来源被隐私策略截断时返回空字符串。“从哪里来”这个信息对流量来源分析很有用,但要清醒认识到它的边界:Referrer-Policy响应头、meta标签里的referrer策略、链接上的rel="noreferrer",都可能把referrer变成空字符串。只要来源页设置了no-referrer,你这边就什么也拿不到。
所以正确地用referrer是:拿到就做统计,拿不到也要有降级策略,不能依赖它做核心业务判定。
5.3 baseURI:被base标签左右的相对地址基准
页面HTML里的<base href="https://cdn.example.com/lib/">会改变页面内所有相对URL的解析基准。此时document.baseURI也会变成这个值。如果你在业务代码里需要把相对路径解析成绝对路径,直接用document.baseURI参与拼接,会比写死当前地址更可靠,因为它包含了base标签带来的影响。
比如实现一个资源路径解析函数:
javascript复制function resolveUrl(path) {
return new URL(path, document.baseURI).href;
}
这样页面配置了base标签也能正确解析,比手动拼字符串稳得多。
5.4 domain:旧时代的子域互信与新浏览器的收紧
document.domain过去是可以赋值的,比如从sub.example.com改成example.com,达到“同源降级”的效果,让子域和主域的页面可以互相操作Cookie和DOM。这个机制在早期互联网环境里确实有用,但也引入了一些安全问题,跨子域攻击曾经就利用过这个口子。
现代浏览器已经逐步封堵这条路:Firefox 68+、Chrome 105+都限制或移除了document.domain的跨子域降级能力。现在的建议很明确:跨域窗口通信统一走postMessage,不要再去动domain。如果你还在维护老代码里依赖document.domain实现子域互信,这个方案需要考虑改造了。
6. 低调但关键时刻救命:characterSet、compatMode、activeElement、currentScript
6.1 characterSet与乱码排查
document.characterSet返回当前文档的字符编码,通常就是UTF-8,也可以通过meta标签的charset属性影响它。遇到中文乱码问题时,先看这个属性能快速确认文档的实际编码,是和预期一致。
需要注意,document.characterSet是文档实际使用的编码,不一定和meta标签里写的一样,浏览器可能在解析时做过自动探测。如果meta声明UTF-8但服务端返回的Content-Type是GBK,最后生效的往往是服务端那边,这种不一致正是乱码的根源。排查时为了一步到位,前端看document.characterSet,后端看响应头Content-Type,两者对齐了基本就没问题。
6.2 compatMode:你是否身处怪异模式的照妖镜
document.compatMode返回两个值:CSS1Compat代表标准模式,BackCompat代表怪异模式。怪异模式是浏览器为兼容旧网页保留的渲染方式,它的盒模型、行高计算、滚动行为都和标准模式差异很大,很多“这里高度不对”的疑难杂症最终都指向它。
判断方法很简单:
javascript复制if (document.compatMode === 'BackCompat') {
console.warn('页面处于怪异模式,建议检查doctype');
}
但要提醒一句:诊断时你确实可以用挂compatMode做兼容逻辑,长期方案永远是修复页面,加上正确的<!DOCTYPE html>声明,别靠JS给怪异模式打补丁。
6.3 activeElement与焦点管理
document.activeElement返回当前获得焦点的元素,没有焦点时通常返回body。它是做表单校验和键盘交互的利器。
比如一个常见场景:表单提交时校验失败,要把光标定位到第一个出错字段。实现上就是判断焦点目前停在哪个input,然后决定要不要移动:
javascript复制const current = document.activeElement;
if (current && current.matches('input, textarea, select')) {
current.blur();
}
messageInput.focus();
高端一点的用法:在shadow DOM里,document.activeElement返回的是包含shadow root的宿主元素,还需要自己绕进去查。这个比较进阶,先知道有这层关系就行。
6.4 currentScript:定位正在执行的脚本
document.currentScript只在脚本执行期间返回当前正在执行的script元素,脚本执行结束后访问会拿到null。它有个经典用途:动态加载脚本时,需要知道“当前脚本”自己加载到了哪里,然后基于它的src推导其他资源路径。
javascript复制// 假设脚本被动态插入到页面,路径是 /assets/js/helper.js
const scriptSrc = document.currentScript && document.currentScript.src;
// 基于scriptSrc去加载同目录下的其他文件
注意:事件回调、setTimeout、Promise回调里访问document.currentScript都会得到null,因为它只在同步执行阶段有效。这个属性适合在模块加载器的实现里用,普通业务代码很少用到,但理解了会有种“原来如此”的畅快感。
另外有几个冷门属性也可以顺带记一下:document.doctype返回DOCTYPE节点,document.contentType返回当前内容的MIME类型,document.lastModified是最后一次修改时间字符串。它们不常用,但排查文档来源问题时能帮上忙。
7. 可写属性背后的setter/getter机制:cookie、title、designMode不是普通变量
7.1 cookie:看似赋值,实则是接口调用
document.cookie是最典型的“看起来像属性,实际是接口”的例子。读它,返回当前文档可访问的cookie字符串;写它,则触发一次Set-Cookie行为。很多初学者以为document.cookie可以像操作对象一样整体赋值,写多个cookie就拼成一个字符串,结果怎么设都不生效。
事实是,一次赋值只能写一个cookie,多个键值对要用分号分隔语句,而不是合起来一次赋值。正确写法:
javascript复制document.cookie = 'token=abc123; path=/; max-age=86400';
document.cookie = 'theme=dark; path=/; SameSite=Lax';
这里还要注意编码问题。cookie值里的分号、逗号、中文都是需要处理的,用encodeURIComponent包装一下更安全:
javascript复制document.cookie = 'nickname=' + encodeURIComponent('张三') + '; path=/';
删除cookie则是把max-age设为0或者把expires设为过去时间,再次赋值同名的key即可。
7.2 title:一个属性如何同步到标签页
document.title是另一个可读可写属性。读它,返回<title>元素的文本;写它,会保持标签页标题同步更新。这个“同步更新标签页”的行为是title属性最典型的反射机制:页面标题变了,浏览器UI立刻跟着变。
业务上最常用的场景是给页面加“未读消息数”,比如聊天页面把标题改成“(3) 工作台”这样的格式。实现上要注意,document.title是纯文本,不能在里面写HTML,写进去会被转义。
7.3 designMode与文档可编辑
document.designMode="on"会把整个文档变成可编辑状态,相当于给body加了一个全局的contenteditable属性。早年间做简易在线编辑器,这个属性简直是神器,一行代码就让整个页面能打字。
现在富文本编辑器都用contenteditable局部控制,很少直接开designMode了,但了解它没坏处。如果哪天需要快速做一个内部工具页的“直接改字”,designMode可能是最快的方案。
顺着这个思路,还可以关注一下document.adoptedStyleSheets、document.fonts这些面向CSSOM和字体加载的属性,它们代表属性体系里比较新的方向,比如adoptedStyleSheets可以在多个组件间共享构造式样式表。这类属性目前在普通业务里还不算高频,但理解了“属性读写背后往往有浏览器级逻辑在支撑”这个底层事实,再遇到新属性就不会觉得神秘了。
8. 落到项目里:document属性排查顺序、常见案例与速查表
8.1 我的排查顺序
在实际项目里遇到“页面显示不正常”的疑难问题,我一般会按照下面的顺序过一遍Document对象属性:
- 先看骨架和滚动:documentElement、body、scrollingElement,确认根节点和滚动容器有没有被样式或脚本改掉。
- 再看加载状态:document.readyState,确认初始化逻辑执行时页面到底处于哪个阶段,是不是事件挂早了。
- 查来源和地址:document.URL、referrer、baseURI,确认当前面页地址和来源是否符合预期。
- 查渲染环境:document.compatMode、characterSet,排除怪异模式和编码不一致。
- 最后看动态状态:activeElement、currentScript,定位焦点和当前脚本的上下文。
这个顺序是从“影响面大的属性”到“影响面小的属性”排列的,能帮我快速缩小排查范围,避免一开始就在某一段具体代码里打转。
8.2 一个综合案例:为什么documentElement.scrollHeight拿到的数字不对
有一个比较典型的案例。页面里实现“回到顶部”和“到达底部”的判断,用document.documentElement.scrollHeight作为文档总高度,结果发现页面明明超出了视口,scrollHeight却比预期小,底部内容判断不到。
排查链路是这样的:
- 先确认当前滚动的是不是html元素。如果页面某个祖先元素上设置了overflow: auto,真正滚动的是它,而不是html,此时document.documentElement.scrollHeight代表的是整个文档高度,但视觉上滚动发生在内部容器,两者对不上。换成document.scrollingElement.scrollHeight也不一定对,要结合布局结构判断滚动容器是谁。
- 检查compatMode。如果页面处于怪异模式,documentElement的scrollHeight语义不太可靠,正确做法是先修doctype。
- 检查图片是否全加载完。如果高度测量发生在图片加载前,图片没撑开高度,scrollHeight就是缺的。这时需要等window.load事件之后再测,或者监听图片的load。
诊断代码可以这样写:
javascript复制window.addEventListener('load', function () {
const scroller = document.scrollingElement || document.documentElement;
console.log('视口高度:', scroller.clientHeight);
console.log('内容总高度:', scroller.scrollHeight);
console.log('渲染模式:', document.compatMode);
});
这个案例里踩到的坑,几乎把前几章的属性都串起来了。
8.3 常见属性速查表
最后放一张实战速查表,方便日常开发时快速查阅:
| 属性 | 可写 | 一句话用途 | 常见坑 |
|---|---|---|---|
| document.documentElement | 只读 | 获取html根元素 | 别把body当根 |
| document.body | 只读 | 获取body元素 | 脚本放head里可能是null |
| document.head | 只读 | 获取head元素 | 用的少,但要区分于body |
| document.scrollingElement | 只读 | 获取当前滚动视口元素 | 需要兼容老浏览器时兜底 |
| document.forms | 只读 | 获取所有form集合 | 活集合,动态变化 |
| document.images | 只读 | 获取所有img集合 | 遍历时不要删除元素 |
| document.links | 只读 | 获取带href的a/area集合 | 不等于全部a标签 |
| document.scripts | 只读 | 获取所有script集合 | 活集合,动态脚本也会出现 |
| document.styleSheets | 只读 | 获取样式表列表 | 不是数组,不能直接forEach |
| document.readyState | 只读 | 判断文档加载阶段 | 事件时序容易搞混 |
| document.hidden | 只读 | 页面是否隐藏 | 新场景用visibilityState |
| document.visibilityState | 只读 | 页面可见性状态 | visibilitychange里读新值 |
| document.URL | 只读 | 当前文档地址 | 跳转要用location |
| document.referrer | 只读 | 来源页面地址 | 可能受隐私策略清空 |
| document.baseURI | 只读 | 基准地址 | 受base标签影响 |
| document.domain | 受限 | 文档域名 | 现代浏览器已限制跨子域 |
| document.characterSet | 只读 | 文档字符编码 | 和后端Content-Type对齐 |
| document.compatMode | 只读 | 渲染模式 | BackCompat就是怪异模式 |
| document.activeElement | 只读 | 当前焦点元素 | shadow DOM下会返回宿主 |
| document.currentScript | 只读 | 当前执行脚本 | 异步回调里是null |
| document.title | 可写 | 读取/设置标题 | 写进去的是纯文本 |
| document.cookie | 可写 |
