
写这篇东西的起因是我接连被同一个人问了两次关于 document.reference 的问题。第一次是他说排查来源统计时发现 document.reference 永远是 undefined第二次是他在网上搜资料发现有人用 document.referrer、有人写 document.reference直接不知道该信谁。说实话这类困惑我在项目里见得太多了。标准的 JavaScript API 里只有 document.referrer这个双 r 的属性才是获取上一个页面地址的正确入口而 document.reference 根本不是浏览器原生的东西。但这篇文章我想讲的远不止一个拼写纠错。围绕document 的引用这个主题牵扯出来的是浏览器里整套页面引用机制、单页应用的来源追踪陷阱、跨窗口通信的安全边界以及框架时代里 document 引用的生命周期管理。这篇文章适合前端入门者、写投放落地页的运营脚本的同学也适合被数据团队反复追问来源为什么是空的开发者。一次讲透后面能少踩很多坑。1. document.reference 到底是个什么东西1.1 先从 API 命名混乱说起要弄清楚 document.reference得先知道它在什么场景下出现。我在搜索引擎里翻过这个词出现频率最高的几类内容一是 document.referrer 的笔误二是自动纠错把 referrer 改成了 reference三是一些非官方教程里以讹传讹写出来的伪 API。为什么偏偏是这两个词容易混因为它们长得太像了而且 referrer 本身就是一个拼写极其别扭的单词。要知道HTTP 协议里那个表示来源地址的请求头字段其实拼错了正确拼写应该是 referer只有单 r但当年写进 RFC 的时候拼写错了后来所有浏览器和服务端实现都沿用了这个错误拼法。而浏览器暴露给 JavaScript 的 DOM 属性 document.referrer 用的又是完整的双 r 拼写。同一个概念一个在协议层一个在 DOM 层拼法还不一样再加上 reference 这个常用词一掺和三个词放在一起新人根本分不清哪个是哪个。如果严格查规范document.reference 没有任何标准定义。在 DevTools 的 Console 里输入 document.reference返回的就是 undefined它不是一个原生成员。但有意思的是在某些框架源码或者项目工具函数里你确实能看到有人把 document 保存进一个叫 reference 或类似名字的自定义字段里用来在模块间共享这个全局对象的入口。所以更准确的说法是document.reference 不是一个浏览器 API但它背后的引用语义确实是理解前端运行时的关键钥匙。1.2 document 是一张引用网的根节点聊 document 的引用不能只停留在拼写层面。从浏览器的工作机制来看页面被解析之后HTML 元素会变成一个一个节点对象这些对象之间靠引用互相连接形成 DOM 树。树的根不是 documentdocument 是整棵树的宿主对象它身上挂着一堆指向树根和关键节点的引用document.documentElement 指向 标签对应的节点document.body 指向 document.head 指向 。你通过 getElementById、querySelector 拿到的每一个元素本质上都是从 document 出发、沿着引用网络找到的一个对象引用。这意味着document 在浏览器里扮演的是一个入口角色。所有对页面元素的访问最终几乎都要从 document 或 window 出发。理解了这一点再回看那些工具函数里保存 document 引用的做法逻辑就通了在一个模块内部提前把 document 引用存下来后续操作 DOM 时不需要每次从全局作用域去查既缩短了代码路径也能避免在某些特殊执行环境下全局对象被遮蔽的问题。这种做法本身没有错但要注意保存引用的时机和使用环境后面我会专门讲生命周期相关的坑。1.3 为什么这个概念值得花一整篇来讲一个不存在的 API 有什么好讲的因为它背后藏着一整类问题。我处理过一起线上数据事故运营同事在推广落地页里埋了一段来源统计脚本代码里写的是 document.reference 取值最后所有渠道来源都统计成了空。排查的时候看到那行代码真是又好气又好笑。它很低级但也很真实——网上复制代码时多打一个字符或者被自动纠错坑一把线上就出问题。这类问题的共性在于我们太容易把浏览器行为当成理所当然。referrer 表面上只是一个属性但它受导航方式、跨域策略、页面配置、浏览器默认行为等多重因素影响。如果只停留在点一下就能用的层面出了问题根本无从下手。所以我这篇的思路很明确先把 document.reference 这种讹传排除掉再把真正能用的 document.referrer 讲透然后扩展到 document 引用的各种实操场景——iframe、多窗口、框架生命周期、内存管理最后给出可直接照抄的排查清单和代码模板。2. document.referrer最容易被忽略的来源字段2.1 referrer 到底返回什么什么时候会变document.referrer 是一个只读字符串属性返回上一个页面的完整 URL。注意是上一个页面不是当前页面也不是来源域名。如果用户直接在地址栏输入网址打开页面没有上一个页面这个属性的值就是空字符串。如果是从站外点击链接跳转进来的它就是那个外站页面的 URL。如果从站内 A 页面跳到 B 页面它就是 A 页面的 URL。这个值不是页面自己能改的它由浏览器根据导航来源自动填充。这里要特别留意几个边界场景刷新页面时referrer 不会变因为刷新在浏览器语义里是同一份文档的重新加载从新标签页打开链接时referrer 通常会被设置成触发打开的那个页面地址但如果链接是用 target_blank 配合 relnoopener 打开的部分浏览器对 referrer 的处理会有细微差异需要单独验证。我的建议是在写任何埋点代码之前先在目标页面的 Console 里手动跑一遍 document.referrer同时把 F12 网络面板里对应文档请求的 Referer 请求头打开对照。这两个值通常是一致的如果出现不一致说明有中间层比如 JS 重定向、Service Worker在干扰这时候要优先看网络请求的原始值而不是 JS 里读出来的值。2.2 单页应用里为什么总是拿不到正确来源真正让人头大的是单页应用SPA。现在很多业务系统、中后台、活动页都基于 vue-router 或 react-router 做客户端路由页面切换根本不经过浏览器完整的文档导航只是 JavaScript 修改了 history 状态、替换了视图组件。在这种情况下URL 变了页面内容变了但 document.referrer 纹丝不动。举一个非常典型的线上场景。用户从抖音广告点击链接进入落地页 A落地页 A 是 SPA 的首页此时 document.referrer 能拿到抖音的链接。用户在 A 页面点了按钮前端路由跳转到站内页面 B如果这时候在 B 页面埋点想要读取来源期望值是 A 页面地址实际上拿到的可能还是抖音链接甚至在某些情况下是空字符串。因为整个跳转过程没有触发新的文档加载浏览器不会重新计算 referrer。处理这个问题的标准做法是自己维护一份来源栈// 在应用入口最早执行的模块里 const SOURCE_KEY __page_source_stack__; function recordSource() { const stack JSON.parse(sessionStorage.getItem(SOURCE_KEY) || []); stack.push({ path: location.href, referrer: document.referrer, time: Date.now() }); if (stack.length 10) stack.shift(); sessionStorage.setItem(SOURCE_KEY, JSON.stringify(stack)); } function getSource() { const stack JSON.parse(sessionStorage.getItem(SOURCE_KEY) || []); // 从最近的记录往前找 return stack.reverse().find(item item.path ! location.href); }在应用启动时先调用 recordSource 记录初始来源每次路由切换后再调用一次。这样无论你站在哪个页面都能从 sessionStorage 里拿出真实的站内来源链路。注意 sessionStorage 只在当前标签页有效这正好符合来源的语义——新开一个标签页就应该重新计算来源。2.3 Referrer-Policy 和跨域信息裁剪让 referrer 失效的另一个大头是策略配置。浏览器通过 Referrer-Policy 协议指定来配合决定 Referer 请求头发送的详细程度而 document.referrer 能够读到什么和这个策略强相关。现代浏览器默认值是 strict-origin-when-cross-origin含义是同源请求发送完整 URL跨域请求只发送源信息HTTPS 降级到 HTTP 时不发送任何内容。这个策略可以用三种方式设置HTTP 响应头、HTML 里的标签、单条链接的 rel 属性。优先级是响应头里的策略 meta 标签 链接级 rel 浏览器默认值。策略值同源请求跨域请求HTTPS 降级 HTTPno-referrer不发送不发送不发送origin只发送源只发送源只发送源same-origin发送完整 URL不发送不发送strict-origin-when-cross-origin发送完整 URL只发送源不发送实操里我遇到过最多的情况是页面代码没动过某天来源统计突然全断排查半天发现是运维在 Nginx 或者 CDN 上给响应统一加了一条 Referrer-Policy: no-referrer所有来源信息直接被压掉了。这种问题的排查方向很多时候不在前端代码里而在链路中间。所以我养成了一个习惯凡是分析来源问题第一步永远是打开网络面板看文档请求的 Referer 请求头。如果请求头里都没有前端再怎么折腾 document.referrer 也没用。另一个容易被忽略的点是 标签上的 relnoreferrer。很多前端在做外链安全处理时会顺手加上 relnoopener noreferrernoopener 是为了防止新页面通过 window.opener 反向操作原页面这个行为很安全但 noreferrer 会把来源信息完全掐断。如果那个外链是你自己的另一个站点而你又希望对方能统计到来源这个属性就要慎重。3. 各种 document 引用 的实操场景与坑3.1 iframe 与跨窗口引用同源是底线document 引用在 iframe 和多窗口场景下的规则一句话总结就是同源是底线跨域只能靠消息。父页面操作同源 iframe可以通过 iframe.contentDocument 拿到子页面的 document 引用然后像操作普通 DOM 一样去操作子页面里的元素。子页面也可以反向通过 parent.document 或者 top.document 访问父页面。这里面有个非常反直觉的点document 引用能不能用取决于页面加载时的源也就是协议、域名、端口三个要素完全一致。哪怕只有端口不同也算跨域contentDocument 会返回 null强行访问就抛安全错误。这个安全策略相当硬没有任何前端技巧能绕过也不应该绕过。我在项目里见过有人试图维护一个跨域 iframe 管理后台想通过父页面直接读取子页面 iframe 里的表单数据。这个思路在同域环境下很顺手但一旦跨域就寸步难行。正确的做法是把获取数据的工作放到子页面内部执行拿到结果后用 postMessage 发给父页面// 子页面里先把数据算好再发出去 const result document.querySelector(#app).innerText; parent.postMessage({ type: IFRAME_RESULT, data: result }, https://parent.example.com); // 父页面里监听消息 window.addEventListener(message, (event) { if (event.origin ! https://child.example.com) return; if (event.data.type IFRAME_RESULT) { console.log(拿到子页面数据, event.data.data); } });这段代码要特别注意 event.origin 白名单校验不校验就处理消息等于给跨站脚本攻击留了一个口子。这条经验在跨窗口通信里通用所有通过引用拿不到的数据都走 postMessage但消息来了必须验来源。3.2 框架里 document 引用的生命周期陷阱Vue 和 React 把开发者从繁琐的 DOM 操作里解放了出来但 document 的引用在组件代码里依然躲不掉。最常见的坑不在获取而在清理。组件挂载时为了监听键盘事件、点击外部区域、滚动位置之类的需求直接在 document 上挂 addEventListener这是大家最爱写的代码。但组件卸载时如果忘了 removeEventListener回调函数会继续存活并且闭包里还持有这个组件实例曾经通过 document 找到的那些 DOM 引用。这个问题的隐蔽性在于它不会立刻暴露而是随着页面反复进出逐渐累积。内存占用缓慢上升事件触发越来越频繁最后表现为页面用着用着就卡了。用 Performance 面板做内存快照对比能看到 detached 节点数量持续增长这种问题排查起来费时费力一个不小心就归因到别的模块。标准解法是把监听器注册和销毁放到同一个生命周期阶段// React 里用 useEffect 统一管理 useEffect(() { const handler () { // do something }; document.addEventListener(keydown, handler); return () { document.removeEventListener(keydown, handler); }; }, []);这里有一个细节removeEventListener 必须要传入同一个函数引用所以 handler 不能是匿名函数必须抽出来命名。另一个更省心的方案是用 AbortController把同一批事件监听器交给一个控制器清理时一次性 abortuseEffect(() { const controller new AbortController(); const { signal } controller; document.addEventListener(scroll, handler, { signal }); window.addEventListener(resize, handler, { signal }); return () controller.abort(); }, []);额外再提醒一个 SSR 的坑服务端渲染环境里根本没有 document 对象。很多新手在组件顶层直接写 document.title在服务端运行时直接报错。判断环境最简单的办法是 typeof document undefined但更符合框架习惯的做法是把这类逻辑放进 useEffect 或者 onMounted 里保证只在浏览器端执行。3.3 闭包引用的内存泄漏背锅的不该是 document关于 document 引用导致内存泄漏其实有一个认知误区需要纠正。document 本身是全局单例它就在那里被多引用一次不会泄漏。真正的泄漏源是 document 查出来的 DOM 元素节点以及这些节点上挂的事件回调链。我踩过一个特别典型的坑。项目里有一个富文本编辑器每次打开弹窗就 new 一个编辑器实例这个实例内部保存了 document.getElementById(editor) 的引用并在 document 上绑定了 keydown 监听。编辑器关闭时业务代码只把实例置空了没有移除 document 上的监听器。打开关闭十几次之后页面明显变卡打字响应延迟严重。原因就是每次绑定的匿名回调函数都闭包持有了当时的编辑器实例而实例又持有那一批 DOM 引用。虽然编辑器 DOM 已经从页面卸载了但引用链没有断开这些节点就永远留在内存里。解决思路很简单编辑器销毁方法里显式移除监听器实例置空之前先解绑。如果用了 AbortController就把 abort 放进销毁流程。记住一句话谁绑的监听器谁负责解绑凡是匿名函数注册的监听器都是排查时的首要嫌疑。4. 常见问题与排查技巧实录4.1 为什么 document.referrer 总是空这个问题在数据埋点、运营投放场景里出现频率极高。我把实际排查中遇到的情况整理成一份速查表按下述场景对照定位比盲改代码要快得多现象常见原因排查方向所有渠道来源都为空页面或站点级设置了 Referrer-Policy: no-referrerF12 看文档请求的 Referer 请求头部分渠道来源为空外链带 noreferrer或 App 内嵌浏览器默认屏蔽核对跳转链接配置和落地页环境SPA 内页面来源不对客户端路由切换不触发文档导航referrer 未更新改用 sessionStorage 维护来源栈只有 HTTPS 页面跳转时为空目标地址是 HTTP降级场景策略裁剪检查 Referrer-Policy 的 downgrade 规则微信/抖音等 WebView 内来源为空宿主 App 的 WebView 配置覆盖了浏览器默认行为在 WebView 环境内手动执行属性读取验证这里有一条铁律前端读 document.referrer 之前先确认网络请求层确实有 Referer 请求头。如果请求头里都是空的前端拿到空值就属于正常现象问题不在前端代码。如果请求头有值但 JS 读出来是空才需要怀疑代码上下文或执行时机。4.2 跨浏览器与 WebView 行为差异速查还有一个让很多人头疼的点同样一段代码在 Chrome 调试没问题一到真机或者特定 App 里就出问题。这些年浏览器对 referrer 的默认策略逐渐统一到 strict-origin-when-cross-origin但移动端 WebView 和部分老内核仍然有自己的行为。浏览器/环境referrer 默认行为Chrome / Edge同源发送完整 URL跨域只发送 originHTTPS 降级不发送Firefox与 Chrome 一致遵循严格模式Safari历史上跨域发送完整 URL 的情况较多新版已收紧Android WebView取决于宿主 App 的 WebView 配置可能是 no-referrer微信内置浏览器多采用跨域只发送 origin 的严格策略抖音/快手等 App 站内跳转不同版本差异极大建议真机实测面对这些差异我更建议把 document.referrer 当成一个尽力而为的来源而不是唯一可信依据。真正常见的稳妥方案是在投放链接里主动拼上来源参数比如 utm_source、channel 之类的 query 参数落地页统一从 URL 里取。URL 参数是业务层自己控制的不依赖浏览器行为可靠性远高于 referrer。4.3 一份可直接抄的引用管理代码把前面所有坑都考虑进去我整理了一套比较稳妥的来源获取与管理模板。它不依赖单一来源而是按优先级逐级回退同时对 document 相关的监听器做统一管理。直接复制到项目里按需调整即可const SOURCE_KEY __source_info__; function getUrlParam(name) { const match new RegExp([?] name ([^]*)).exec(location.href); return match ? decodeURIComponent(match[1]) : ; } function getTrackSource() { // 第一优先级URL 上显式携带的来源参数 const urlSource getUrlParam(utm_source) || getUrlParam(channel); if (urlSource) return urlSource; // 第二优先级站内来源栈里的最近一条 try { const stack JSON.parse(sessionStorage.getItem(SOURCE_KEY) || []); const last stack[stack.length - 1]; if (last last.path ! location.href) return last.path; } catch (e) { // 存储不可用时忽略 } // 第三优先级浏览器提供的 referrer return document.referrer || ; }这套代码的核心思路是优先相信业务层主动传递的信息其次才是浏览器行为。sessionStorage 的来源栈在应用启动和路由切换时写入逻辑和前面 2.2 节的一致。真实项目里顺序可以调整但方向不要反过来——把 document.referrer 作为兜底而不是唯一来源能省掉大量将来排查的问题。写到最后聊点实际的。document.reference 这个词翻遍任何标准文档都找不到但它背后带出来的一连串问题我前前后后处理过不少回。印象最深的一次不是技术问题而是业务方拿着一份全空的来源报表来找我一口咬定是埋点代码没写对。最后查下来是运营配置落地页时给响应头加了一条禁止携带来源的策略所有流量进来时来源信息被浏览器直接压掉。代码没错技术没错错在对来源这个概念的理解不在一个频道上。所以我在团队里一直强调凡是依赖 document.referrer 这类浏览器行为的逻辑先搞清楚它的边界再把兜底方案设计好。引用关系是浏览器给的但数据的可靠性必须自己兜。这也是我写这篇文章最想传达的一点经验。