如果你写过哪怕一行和页面交互的 JavaScript,就一定碰过document。说白了,Document 对象就是网页文档在 JS 世界里的“总代理”:标题、地址、表单、图片、加载状态,全都挂在这棵树上。但很多人查资料时只记住了document.getElementById、document.querySelector这些方法,回头问一句“Document 对象有哪些常见属性”,反而答不上来。这篇文章就把我平时真正用得上的属性拉出来,从身份信息、结构集合、运行状态到容易踩坑的地方,逐个分析它的用途、取值方式和背后的设计逻辑。刚学完 JavaScript 基础、想系统整理 DOM 知识,或者已经开始写页面脚本但被document.title、document.body这类属性闹过乌龙的朋友,这份整理应该能派上用场。
1. 先看身份信息:别人通常怎么确认一个页面
1.1 document.title:不只是浏览器标签页那一行字
大多数人对document.title的第一印象就是“网页标题”,但它在实际开发里的地位远不止这个。这个属性是可读可写的字符串,读取时返回<title>元素里的文本,写入时会直接改变浏览器标签页、任务栏、历史记录里显示的名字。我之前做过一个 H5 活动页,要求用户分享时带有不同的标题文案,一开始直接手动改<title>,后来发现每次切换状态都要重新查 DOM,既不优雅还容易漏。改成一处统一管理:
// 读取当前标题 console.log(document.title); // "我的网站" // 动态修改标题,适合单页应用在路由变化时调用 document.title = "订单详情 - 我的网站";关键点在于,document.title修改后不会触发页面的重新渲染,也不会导致布局抖动,这跟操作innerHTML完全是两码事。它影响的其实是浏览器 UI 层和浏览器历史记录。很多单页应用框架在做路由切换时,只改了 URL 忘了改document.title,结果用户看到标签页始终是同一个名字,后退按钮里的标题也都是旧的,这就是典型的“属性没同步”问题。
有一点容易忽略:如果文档里根本没有<title>标签,document.title会返回空字符串"",而不会返回你误以为的文件名或 URL。在做页面数据统计时,不要依赖它判断“页面有没有被正确加载”,因为空标题也照样能加载出来。需要区分的时候,更稳妥的做法是同时看document.title和document.URL是否都有值。
1.2 document.URL 和 location.href:别把两兄弟搞混
document.URL返回当前文档的完整 URL,是一个纯字符串属性,只读。很多人会直接拿location.href来替代,这两个值大部分情况下一致,但有一个重要区别:location.href可以赋值,赋完值页面就会跳转;document.URL不能赋值,你写document.URL = 'https://example.com'不会报错,但页面纹丝不动。这个“静默失败”非常坑,我曾经在代码里把document.URL当成可写属性,结果调试了半天。
除了document.URL,还有document.documentURI。document.URL是历史遗留,document.documentURI更通用,内容基本一致,推荐新代码用document.documentURI或者直接location.href。要看 URL 的各个组成部分,比如协议、主机名、哈希,我建议直接解析location对象,而不是去切割字符串:
// 当前页面地址的完整字符串 console.log(document.URL); // 更推荐的做法:利用 location 对象拿各个部分 const url = new URL(document.URL); console.log(url.hostname); // 'example.com' console.log(url.pathname); // '/path/to/page' console.log(url.searchParams.get('id')); // 查询参数1.3 document.domain:放宽同源限制的“临时通行证”
document.domain属于身份信息里比较特殊的一个属性,它既少见又危险。正常情况下,如果页面在a.example.com,document.domain的值就是"a.example.com",并且这个属性允许被设置成当前域名的“父域”,比如:
// 在 a.example.com 页面里执行 document.domain = "example.com";设置之后,同属example.com下的其他子域页面,只要都执行了同样的赋值,它们之间就可以互相读取对方的document信息,从而绕过浏览器默认的同源策略,实现跨子域通信。这在以前的多子域系统里非常常见。
但我要强调,这个属性有几个限制:第一,你只能设置成当前域名的父域,不能设置成完全无关的域名;第二,一旦把document.domain设置成更宽的域,就不能再改回更具体的子域,这个操作是不可逆的;第三,现代浏览器对它的限制越来越严格,很多新的接口已经不再信任放宽后的同源关系。我建议新项目尽量别依赖它,如果真有跨子域需求,优先考虑postMessage或服务端配置 CORS。
2. 页面结构入口:Document 对象下的快捷方式
2.1 document.documentElement 和 document.doctype:一个管根,一个管模式
document.documentElement返回文档的根元素,标准 HTML 页面里就是<html>。很多初学者会奇怪:“为什么不是document.rootElement?”因为在 XML 或其他类型的文档里,根元素不一定是<html>,所以规范上统一用documentElement这个名字。这个属性最常见的用处有两个:拿页面整体滚动高度、视口尺寸,以及在标准模式下访问<html>上的自定义属性。
// 页面总高度(含滚动部分) const scrollHeight = document.documentElement.scrollHeight; // 页面当前滚动距离 const scrollTop = document.documentElement.scrollTop || document.body.scrollTop;为什么滚动相关操作更推荐documentElement而不是document.body?因为不同内核的浏览器对document.body.scrollTop的支持不一致,而document.documentElement在标准渲染模式下更稳定。严格模式下,如果你用document.body.scrollTop去拿滚动距离,很可能拿到0。
document.doctype则返回文档的 DTD 声明对象,比如<!DOCTYPE html>,它不是一个 DOM 元素节点。这个属性实用性看着不高,但可以用来判断页面是否写了 doctype。如果一个页面没有正确声明 doctype,浏览器会进入怪异模式(Quirks Mode),布局细节会变得很难控。检测方式很简单:
const doctype = document.doctype; console.log(doctype); // <!DOCTYPE html> 或 null console.log(doctype.name); // "html"2.2 document.head 和 document.body:两个最常用的“容器入口”
document.head返回<head>元素,document.body返回<body>元素,二者都是只读属性,返回的是元素节点。之所以把它俩单独列为 Document 属性,是因为它们为“快速找到页面两大区块”提供了直接入口,省得写document.querySelector('head')这种啰嗦查询。
但这里有个我踩过很多次坑的细节:在文档的<head>内部脚本中,document.body是null。因为解析器还没执行到<body>标签,DOM 树里还没有 body 节点。看下面这段代码:
<!DOCTYPE html> <html> <head> <script> console.log(document.head); // <head>...</head> console.log(document.body); // null </script> </head> <body> ... </body> </html>如果你在 head 里直接写document.body.appendChild(...),页面会瞬间报错。这也是为什么很多代码片段要包裹在DOMContentLoaded事件里,或者把<script>标签放在<body>末尾。我们给文档动态注入脚本时也要注意这个时机,比如在 head 中创建一个script元素,想插到 body 里去,这时候就得先判断document.body是否已存在。
2.3 集合属性一网打尽:forms / images / links / scripts / anchors
Document 对象内置了一批以元素类型命名的集合属性,它们的值都是HTMLCollection,也就是像数组但不是数组的“活集合”。最常见的几个如下:
| 属性 | 返回的内容 | 是否实时更新 |
|---|---|---|
document.forms | 所有<form>元素 | 是 |
document.images | 所有<img>元素 | 是 |
document.links | 带href特性的<a>和<area>元素 | 是 |
document.scripts | 所有<script>元素 | 是 |
document.anchors | 带name属性的<a>元素 | 是 |
document.styleSheets | 所有样式表对象 | 是 |
这些集合最大的价值在于“快捷遍历”。比如在调试页面时,我想快速看页面到底加载了几个脚本:
console.log(document.scripts.length); document.scripts.forEach? // 注意:HTMLCollection 没有 forEach对,HTMLCollection本身没有forEach方法,只有HTMLCollection.item、namedItem这类。想遍历的话,可以转成数组再用:
Array.from(document.scripts).forEach(function(script) { console.log(script.src); });这里特别提醒:document.links和document.anchors很容易被混淆。前者包含所有带href的<a>元素,也包含<area>;后者只包含带name属性的<a>元素,这是早期锚点导航时代的遗留属性。如果你是为了获取超链接,请用document.links;如果你是为了兼容特别老的页面,才需要关心document.anchors。
还有一个“活集合”的概念要注意。document.forms这类属性返回的是实时集合,意思是后面 DOM 中新增或删除了对应元素,集合内容会自动更新,你不用重新获取。但也因此带来一个隐患:遍历集合时如果同时删除了集合中的元素,索引错位可能会导致漏项。比如:
const forms = document.forms; for (let i = 0; i < forms.length; i++) { forms[i].remove(); // 一边删一边按原长度遍历,会跳过部分后续元素 }这种场景最好倒序遍历,或者用数组拷贝先固定快照。
2.4 document.all:老古董,但偶尔会碰到
document.all是很容易引发争议的属性。它返回文档中所有元素节点的集合,理论上包括<html>本身。这是早期 IE 提供的能力,虽然现在所有主流浏览器都保留了这个属性,但在标准里是个“非规范性”存在。
有意思的是,它的判定逻辑非常奇葩:在现代浏览器里,typeof document.all === 'undefined',但document.all本身又能正常访问。也就是说,你既不能用typeof判断它存不存在,又不能完全忽略它。很多老代码用它来检测浏览器内核:
if (document.all) { // 早期 IE 专属分支 }现在的浏览器里这段判断依然会进入分支,但因为它本身不符合 W3C 规范,我强烈建议:新代码不要写document.all。真要遍历所有元素,用document.querySelectorAll('*')或者document.body.getElementsByTagName('*')都更可控。会被面试问到的概率不低,但实际项目里它是个不推荐的属性。
3. 看懂页面“运行状态”,不只能靠事件
3.1 document.readyState:页面现在到哪一步了?
document.readyState是一个非常直观的状态属性,它有三个字符串取值:
"loading":文档还在加载解析中;"interactive":文档解析完成,子资源可能还在加载;"complete":文档和所有子资源都加载完成。
这个属性能用来判断 DOM 是否已经准备好,避免操作节点时报null。我之前做过一个在页面加载后自动聚焦输入框的需求,脚本放在<head>里,如果直接运行就会因为document.body还没生成而失败。用readyState做个分支就稳了:
function initFocus() { const input = document.querySelector('#name'); if (input) input.focus(); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initFocus); } else { // 此时 DOM 已可安全操作 initFocus(); }很多人喜欢始终监听document.addEventListener('DOMContentLoaded', ...),这也行,但要注意:如果页面本身已经过了加载阶段,事件不会再触发,监听器会一直闲着。结合readyState判断,才是更稳的做法。这也是面试里常被问到的“事件绑定和状态检查的差异”。
3.2 document.hidden 和 document.visibilityState:页面是否被用户看见
这套属性对页面性能优化非常有用。document.hidden返回布尔值,表示页面是否处于“至少被部分不可见”的状态,而document.visibilityState返回更完整的字符串,取值通常是"visible"、"hidden",少数情况是"prerender"。
实际开发中,我经常用它们暂停不需要继续运行的任务,比如视频播放、长轮询、动画循环。切换页面时如果还让视频发声,既影响用户体验,也白白消耗性能。代码如下:
document.addEventListener('visibilitychange', function () { if (document.hidden) { video.pause(); } else { video.play(); } });注意,visibilitychange事件和document.hidden是一对组合拳。document.hidden是布尔,比较适合快速判断;visibilityState更精确,以后如果浏览器引入更多状态,建议优先用它做分支。
3.3 document.compatMode:标准模式还是怪异模式?
document.compatMode返回"CSS1Compat"(标准模式)或"BackCompat"(怪异模式)。判断页面有没有进入怪异模式,最简单的方法就是看它的值。绝大多数情况下我们当然希望是标准模式,因为怪异模式会带来大量历史遗留的盒模型差异,比如元素的宽高计算方式不一致。
但有时候,页面本身没问题,却在 iframe 里被父页面某些设置影响,导致渲染模式异常。遇到诡异的布局 bug 时,我第一件事就是先输出来看:
console.log(document.compatMode); // 应该输出 CSS1Compat如果输出了BackCompat,优先检查是不是少了<!DOCTYPE html>声明,或者声明写错了位置、拼错了单词。这是布页面排版问题的“体检指标”,新手往往不知道。
3.4 document.characterSet:编码属性不能忽略
document.characterSet返回当前文档使用的字符编码,比如"UTF-8"、"GBK"。以前还有个document.inputEncoding,现在基本被characterSet取代。这个属性在调试乱码问题时很有用,尤其是老系统页面上报乱码,先看这个值是否和Content-Type声明一致。如果页面声明是 UTF-8,但流式加载时服务端返回了 GBK,就会出现标题乱码、表单提交乱码。
console.log(document.characterSet); // "UTF-8"3.5 document.activeElement:当前焦点到底给了谁?
document.activeElement返回当前获得焦点的元素,是Element类型。如果没有元素获得焦点,在大多数浏览器里会返回document.body。这个属性在做键盘交互时特别有用。比如我做过一个表单页面,用户按下回车时需要自动跳到下一个输入框,判断当前焦点在哪就要用document.activeElement:
document.addEventListener('keydown', function (event) { if (event.key === 'Enter') { const current = document.activeElement; if (current.matches && current.matches('input')) { const next = current.nextElementSibling; if (next) next.focus(); } } });还有一点需要注意:在iframe场景下,document.activeElement指的不是整个页面全局焦点,而是当前文档内聚焦的元素。跨 iframe 查焦点要到对应的contentDocument下看,直接在主文档里拿往往得到的是 iframe 元素,而不是 iframe 里面的输入框。
4. 和全局对象、位置信息纠缠不清的属性
4.1 document.defaultView:绕路拿 window 的方法
document.defaultView返回与当前文档关联的window对象。通俗说,它就是“从 document 回到 window 的桥”。在普通主页面里,这个属性基本等于window,看起来有点多余;但在 iframe 或其它嵌入式文档里,这个属性就非常关键了。
比如你在主页面里拿到了 iframe 的contentDocument,想通过这个 document 访问 iframe 内部的全局变量,直接写iframe.contentWindow也行,但有时候你手里只有 document 引用,没有窗口引用。这时候就可以:
const frameDoc = document.querySelector('iframe').contentDocument; const frameWin = frameDoc.defaultView; frameWin.someGlobalMethod();如果文档没有关联的浏览上下文(比如某些离线创建的 document 节点),defaultView会返回null,使用时最好判空。
4.2 document.location:别把 location 对象搞成 URL 字符串
document.location是一个Location对象,包含 href、protocol、host、pathname、search 等属性,还支持assign()、replace()、reload()等方法。它和window.location基本一致,但要注意:你写成document.location时,很多人以为它是字符串,可它偏偏是对象。如果直接做字符串拼接,容易出现[object Location]的输出。
想拿纯字符串,请明确写document.location.href或document.URL。想跳转,可以用document.location.href = '/next'。跟document.URL不同,document.location.href是可写的,这也是它最常用的场景。
4.3 document.styleSheets:样式表也是文档属性,不是 DOM 属性
document.styleSheets返回一个StyleSheetList,包含文档中所有样式表,既有<link>引入的外部样式,也有<style>内嵌样式。很多人处理样式时只想到element.style和getComputedStyle,忽略了这套接口。做主题换肤时,我喜欢遍历document.styleSheets,去修改某个规则里的属性值:
Array.from(document.styleSheets).forEach(function (sheet) { if (sheet.href && sheet.href.includes('theme.css')) { sheet.disabled = true; // 禁用一个主题样式表 } });注意,document.styleSheets里每一项是CSSStyleSheet对象,可以通过cssRules查看某一条规则,但要小心跨域样式表访问cssRules会抛异常,因为浏览器不允许读取其他域名的样式规则。
4.4 document.embeds / document.plugins:插件时代的遗产
document.embeds和document.plugins都返回文档中嵌入对象(如<embed>、<object>)的集合。它们现在已经很少用了,但偶尔维护老项目会遇到。特别是早期页面用<object>去加载 PDF 或 Flash,这两组属性是排查页面内嵌对象的出口。现在 HTML 标准里,document.plugins也被定义为一个HTMLCollection,但通常长度是 0,遇到也没有太多实战价值。了解其存在即可,不必深挖。
5. 实操中的坑和排查技巧
5.1 跨文档取属性,经常拿到 null
很多时候我们在 iframe 或window.open出来的新窗口里操作 document,如果不确认文档是否已经加载完,直接读取document.body一样可能拿到null。比如在新窗口刚打开的瞬间,newWindow.document.body还没生成。此时应该等待newWindow.document.readyState变化,或者监听newWindow.document的DOMContentLoaded事件。
5.2 集合属性在异步回调里的失效问题
document.forms这类活集合虽然有实时更新的优势,但如果你在异步回调里使用它,同时又删改了页面里原来记录的元素引用,可能会拿到已经被移除的节点。举个例子:
const formRef = document.forms[0]; setTimeout(function () { // 此时页面上的第一个表单可能已经被替换 console.log(formRef); // 可能是断掉的引用,也可能还有值 }, 500);如果逻辑复杂,我习惯先在变量里保存稳定 ID,再通过document.getElementById重新查找,避免引用了“游离节点”。
5.3 修改 document.title 被浏览器策略限制
虽然document.title是可写属性,但某些外部因素会影响最终效果,比如浏览器设置“不显示网页标题”、网页被强制离线缓存等。更实际的一个坑是:在iframe里修改document.title,默认只会影响 iframe 对应的标签页信息吗?在实际测试中,iframe 内部修改 document.title 会传导到父页面标签标题,依赖浏览器实现,但并不是所有情况下都可靠。如果业务强依赖,最好在主文档里手动设置,或者用window.top.document.title强制改最顶层的标题。
5.4 Document 属性排查速查表
| 现象 | 可能原因 | 排查建议 |
|---|---|---|
脚本一运行就报错Cannot read property 'appendChild' of null | document.body为 null,脚本位置在<head>内且未等待 DOM | 改用DOMContentLoaded或把脚本放到 body 末尾 |
| 页面出现怪异排版,和 CSS 预期不一致 | document.compatMode返回BackCompat | 检查是否缺少<!DOCTYPE html> |
document.forms遍历时漏元素 | 活集合在遍历中被修改 | 改用数组快照或倒序遍历 |
| 跨 iframe 调用内部函数失败 | document.domain未设置或设置范围不匹配 | 考虑postMessage通信,尽量不依赖 domain |
页面标题没变但你明明设置了document.title | 路由切换后,被后续代码覆盖了 | 统一封装setPageTitle(),只在最后一步设置 |
一点个人体会
把 Document 对象的属性按“身份信息、结构集合、运行状态、全局关联”这条线整理后,你会发现很多 API 其实是配套出现的。比如想看 DOM 是否就绪,状态属性readyState和事件监听是一套;想安全追加节点,body/head属性和加载时机是一套;想调试跨 frame 问题,defaultView和domain又是一套。平时写代码不要死记属性名字,先判断“我到底是想知道页面身份、拿节点集合、还是确认运行状态”,再去找对应的属性会顺很多。如果让我只说一个建议,那就是先学会设计前先打印一下document.readyState和document.body,很多离奇报错都能从这两个值里看出端倪。