写前端的同学应该都遇到过这种场景:页面在 a.com,用户点一下“去授权”之后要被浏览器带到 b.net 的落地页,而落地页又需要知道用户刚才在 a.com 选了哪个业务入口、语言版本或者活动编号。数据本身不敏感,是典型的临时业务数据,但问题出在“跨域”这两个字上。第三方域名上服务端不开 cookie 写入通道,跨域 sessionStorage 完全不共享,URL 参数又容易被网关日志记下来,而且一长就容易截断。我当时翻了一圈老方案,最后回到了 window.name 这个有点“上古”但依然好用的属性上。
window.name 最特别的地方在于:它是挂在浏览上下文上的,而不是挂在某个页面文档上的。页面 A 跳转到域名完全不同的页面 B,A 写的 window.name 依然能被 B 读到。这种能力用来跨域存储不敏感的临时业务数据,往往比改造服务端配置要快得多。这篇文章会从底层机制讲起,然后给出一套可复用的封装、三种真实落地场景,再聊清楚它的容量上限、使用寿命和清理策略。正在做登录跳转、第三方页面承接、低代码活动页这类前端需求的人,应该都能用得上。
1. 从 window.name 的特性讲起,它凭什么能跨域保存数据
1.1 一张贴在浏览器标签页上的便利贴
很多人第一次接触 window.name 是因为 window.open 指定窗口名,但这东西真正的价值,是它在页面导航之后不会消失。你可以打开任意页面,在 console 里执行:
javascript复制window.name = 'hello-cross-domain';
location.reload();
刷新完成后页面重新执行,但再执行 console.log(window.name),返回的还是 hello-cross-domain。这就有点反常识了,刷新意味着文档重新加载,JavaScript 变量应该全部重新初始化才对。之所以没丢,是因为 window.name 不属于 document,它属于当前这个浏览上下文(browsing context),也就是这个标签页或者 iframe 本身。
我一般把 window.name 想象成一张贴在标签页外面的便利贴。页面 A 往便利贴上写了一行字,然后浏览器带着这张便利贴导航到了域名 B 的页面,B 页面把便利贴撕下来看,发现字还在。同源策略管的是“页面之间能不能互相访问对方的 DOM 和属性”,但它管不到“当前脚本访问自己所在窗口的 window.name”,因为对页面 B 来说,它只是读自己的窗口属性,根本没去碰 A 的东西。
这里有一个很关键的点需要说清楚:如果是在同一标签页里靠 location 跳转过去,那 A 和 B 共享同一个浏览上下文,window.name 天然就能传递。但如果是 window.open 打开的新窗口,那就是两个独立的浏览上下文了,A 塞进 window.name 的数据,新窗口里读不到,这点后面落地场景里还会再强调。
1.2 主流存储方案的对照与取舍
既然 localStorge、sessionStorage 这些新 API 都有了,为什么还要折腾 window.name?因为它确实占着一个特殊的位置。下面这个表是我在做技术选型时常用的对比:
| 存储方式 | 跨域可用性 | 生命周期 | 容量 | 典型问题 |
|---|---|---|---|---|
| window.name | 同标签页跨域导航保留 | 标签页关闭即销毁,刷新保留 | 约 2MB | 需要手动清理,不区分源 |
| localStorage | 按源隔离 | 持久 | 约 5MB | 跨域不可直接读,清缓存才消失 |
| sessionStorage | 按源隔离 | 标签页会话 | 约 5MB | 跨域跳转后读不到,copy tab 也丢 |
| cookie | 可按 domain/path 共享 | 可设过期 | 约 4KB | 会随请求发到服务端,受 SameSite 限制 |
| URL 参数 | 天然跨域 | 随导航结束 | 无严格限制 | 长度受限,会进访问日志和服务端日志 |
| postMessage | 跨域窗口通信 | 事件触发后消失 | 无严格限制 | 接收方必须写监听,需要双方先建立连接 |
从表格里能看出来,sessionStorage 和 window.name 都是跟着标签页走的,但 sessionStorage 死死按源隔离,跨域页面一换就什么都读不到;localStorage 倒是能存更多,可跨域访问直接报 SecurityError,除非你把域名指纹迁移那一套做出来;cookie 在跨域场景里能力不弱,可是每次请求都要背到服务端,而且浏览器对第三方 cookie 的限制越来越严。
window.name 的价值恰好在于它是一个“跨源但不持久”的临时槽。如果你的业务诉求本身就是“我跳转过去,对方在加载那一刻能拿到一段临时数据就行”,并且这段数据不敏感、不要求落盘,那它可能是所有方案里改动最小、逻辑最简单的一个。像登录授权页跳转、第三方服务中转、活动页串联这些场景,目标页面根本来不及执行别的通信逻辑,window.name 随文档加载就能读到的特性反而成了最大优势。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先封装一个带命名空间的存储壳,别裸写
2.1 裸写 window.name 的项目最后都变成什么样了
如果只是临时在 console 里测一下,直接 window.name = 'someValue' 没有问题,但真要放到业务代码里,裸写会很快失控。
举个实际例子:页面 A 要往落地页传一个活动编号,代码写的是 window.name = 'activity_071'。过两天另一个模块上线,也想利用 window.name 在跳转时保留某个 UI 状态,于是也来一句 window.name = JSON.stringify({ theme: 'dark' })。两个模块根本不知道对方的存在,谁后执行谁就把前面的数据覆盖掉。更麻烦的是,如果读数据的一方只是简单 JSON.parse(window.name),万一 window.name 里残留的是历史版本某个裸字符串,页面直接抛异常。
还有一个容易忽略的问题是“脏数据残留”。window.name 在导航后不消失,意味着你在页面 A 写了一段数据,跳到页面 C 之后没消费也没清理,那么用户继续从页面 C 跳到页面 D,页面 D 依然能看到同一份数据。这些跨页面残留数据一旦被下游代码误读,排查起来非常浪费时间。
所以我的建议是,在项目中永远不要直接操作 window.name 这个裸属性,而是统一封装成一个带命名空间的存储模块。每个业务模块用自己的 key 去读写,底层对 window.name 做 JSON 序列化和反序列化,同时对大小超限、解析失败、写入失败做兜底。这样即使未来有别的历史代码在用 window.name,至少你自己这边不会互相污染。
2.2 轻量封装代码可直接复制
下面这套封装我用过好几个项目,基础能力包含 get、set、remove、take、clear,以及存储量检测。它不是完整的 npm 包,就是单文件工具,放到项目的 utils 里就能跑。
javascript复制const NameStore = {
PREFIX: 'wnb1:', // 命名空间前缀,防止和其他旧逻辑混用
MAX_BYTES: 2 * 1024 * 1024 - 128,
readRaw() {
try {
return window.name || '';
} catch (e) {
return '';
}
},
parse() {
const raw = this.readRaw();
if (!raw.startsWith(this.PREFIX)) {
return {};
}
try {
const obj = JSON.parse(raw.slice(this.PREFIX.length));
return obj && typeof obj === 'object' && !Array.isArray(obj) ? obj : {};
} catch (e) {
// 解析失败时不能直接抛错,否则会拖垮业务主流程
return {};
}
},
write(obj) {
const json = JSON.stringify(obj || {});
const text = this.PREFIX + json;
if (new Blob([text]).size > this.MAX_BYTES) {
throw new Error('window.name payload 超过约 2MB,当前数据不适宜继续保存在 window.name 中');
}
try {
window.name = text;
return true;
} catch (e) {
console.warn('window.name 写入失败,部分浏览器隐私模式可能禁止该操作', e);
return false;
}
},
get(key) {
const data = this.parse();
return key === undefined || key === null ? data : data[key];
},
set(key, value) {
if (!key) {
throw new TypeError('NameStore.set 需要传入合法的 key');
}
const data = this.parse();
if (value === undefined) {
delete data[key];
} else {
data[key] = value;
}
return this.write(data);
},
remove(key) {
const data = this.parse();
delete data[key];
return this.write(data);
},
// take 会读取并删除该 key,适合“一次性消费”的临时数据
take(key) {
const data = this.parse();
const value = data[key];
delete data[key];
this.write(data);
return value;
},
clear() {
try {
window.name = '';
} catch (e) {
console.warn('window.name 清理失败', e);
}
},
size() {
return new Blob([this.readRaw()]).size;
}
};
export default NameStore;
这个封装里面 PREFIX 特别重要。我见过有些业务把 window.name 当成一个全局 JSON 对象来用,如果哪一次写入的时候没有加前缀,后面所有解析逻辑都会失效。加一个 wnb1: 前缀,相当于给自己划定势力范围,读到旧数据或者别人写的数据时,可以直接当成空对象处理,不会因为 JSON 格式不匹配而白屏崩溃。
另一个值得说清楚的是 take 方法。临时数据最怕的就是“被读了但没人删”,take 把读和删合并成一个原子操作,拿到值的同时马上从 window.name 里移除,从根源上减少脏数据残留。这个习惯一旦养成了,后面因为 window.name 跨页面串数据而找上门的 bug 能少一大半。
2.3 两个使用约定要写进团队规范
光有封装还不够,使用约定更重要。我这里有两个习惯,写出来供参考。
第一个约定是“临时数据只在一个很短的时间窗口内有效,谁消费谁负责清理”。写入方在跳转前写,接收方在页面加载后立刻 take。接收方永远不要用 get 去读一个本应只出现一次的数据,因为一旦用 get,就会留下一个“读过了但数据还在”的隐含状态,跳转到第三页时它还在。如果接收方还没准备好,也应该在页面初始化流程里先调用 NameStore.remove('key') 做防御性清理。
第二个约定是 key 只能用常量字符串,比如 'ACTIVITY_ID'、'LOCALE_INFO'、'PAGE_SOURCE',不要动态拼接用户输入。使用常量有几个好处:一是可追踪,全局搜这个 key 能定位到所有的写和读;二是不会出现不同页面因为拼写差异导致读不到数据;三是动态 key 很容易把用户输入带进 window.name,万一用户输入里有特殊字符,解析时不安全。
3. 三种真实跨域场景落地
3.1 场景一:同标签跳其他域名,落地页直接读
这个场景几乎每天都在发生,却很少有人往 window.name 上想。假设你有一个主站 a.com/business.html,用户填写完一个表单后点击跳转到第三方 B 域名开始后续流程。你需要在落地页判断用户刚才选择的语言和渠道来源,但又不想把这些参数堆在 URL 上。
页面 A 侧代码可以先写数据,再触发跳转:
javascript复制import NameStore from '../utils/nameStore';
function jumpToPartner() {
const locale = { lang: 'zh-CN', region: 'CN', source: 'landing-card' };
NameStore.set('SESSION_META', locale);
window.location.href = 'https://b.net/service?from=a.com';
}
这里的关键是,跳转之后页面 B 和页面 A 处于同一个标签页中,window.name 挂在标签页上而不是挂在 A 页面上。所以 B 页面加载脚本时可以非常直接地读到:
javascript复制import NameStore from '../utils/nameStore';
function resolvePageMeta() {
const meta = NameStore.take('SESSION_META');
if (meta && meta.lang === 'zh-CN') {
// 初始化中文界面
}
}
可能有人会问,B 页面是第三方域名的页面,它没有你 a.com 的 utils 模块,怎么用 NameStore?这个问题要分两层看。
如果 B 页面完全由对方团队维护,你们可以做技术对齐,让对方在页面里嵌入相同的取数逻辑;如果 B 页面只能静态托管、不能加业务脚本,那 window.name 方案就不适用,你只能用 URL 参数或服务端中转。需要明确的是:window.name 跨域能力依赖的是接收页面自己执行的 JS,不是发送方单方面能完成的事。
这类场景我实际测试过,主流浏览器从 a.com 跳 b.net 时 window.name 会保留。万一你在某个小众浏览器或 WebView 里发现 name 被重置了,可以用接收页面的 console 先执行一下 console.log(window.name) 确认,如果确实取不到,就降级用 URL 参数补一个兜底。
3.2 场景二:用隐藏 iframe 加同源代理页读取外部域的 name
前面讲的场景是发送方把数据写到自己的 window.name 里,再让接收方在同一标签页读到。但如果需求反过来:当前页面想读取另一个域名的 iframe 页面里存好的某段数据,能不能直接读呢?
直接读是不行的。父页面 a.com 和 iframe 里的 b.com 不同源,对 iframe.contentWindow.name 的访问会被浏览器拦截,代码会抛 SecurityError。正确做法是先把 iframe 导航到一个与父页面同源的空白页,把 document 的同源状态先“拉回来”,再读取 window.name 属性。因为 window.name 值属于那个浏览上下文,导航不会把它清掉。
这里给一个可运行的封装思路:
javascript复制/**
* 读取一个外部域名 iframe 页面写入的 window.name
* @param {string} targetUrl 外部域页面地址,加载后需自行写入 window.name
* @param {string} blankUrl 与当前页面同源的空白 HTML 地址
*/
function readCrossDomainName(targetUrl, blankUrl, timeout = 8000) {
return new Promise((resolve, reject) => {
const iframe = document.createElement('iframe');
iframe.style.position = 'fixed';
iframe.style.left = '-9999px';
iframe.style.width = '0';
iframe.style.height = '0';
iframe.style.border = 'none';
let phase = 'waitTargetLoad';
let timer = null;
const cleanup = () => {
if (timer) clearTimeout(timer);
if (iframe.parentNode) {
iframe.parentNode.removeChild(iframe);
}
};
timer = setTimeout(() => {
cleanup();
reject(new Error('读取 window.name 超时'));
}, timeout);
iframe.addEventListener('load', () => {
if (phase === 'waitTargetLoad') {
// 第一次 load:target 页面已执行并写入 window.name,此时跨域不可读
phase = 'switchedToBlank';
try {
iframe.contentWindow.location.replace(blankUrl);
} catch (e) {
cleanup();
reject(e);
}
} else if (phase === 'switchedToBlank') {
// 第二次 load:iframe 已切到同源空白文档,可以安全读取 name
try {
const text = iframe.contentWindow.name;
cleanup();
resolve(text);
} catch (e) {
cleanup();
reject(e);
}
}
});
iframe.src = targetUrl;
document.body.appendChild(iframe);
});
}
调用方式大致是这样:
javascript复制readCrossDomainName('https://b.net/carrier.html', '/blank.html')
.then((raw) => {
console.log('从外部域拿到的窗口名数据:', raw);
})
.catch((err) => {
console.error(err);
});
这里要求 blankUrl 必须和当前页面同源,你可以自己在站点根目录放一个内容为空的 blank.html,不要直接依赖 about:blank。虽然在很多现代浏览器里 about:blank 可以继承父页面源,但不同安全策略下行为并不一致,用一个确定同源的空白资源是最稳的做法。
使用这段代码还有一个前提:targetUrl 页面必须在你或者合作伙伴的可控范围内,能在页面加载时主动把业务数据写进自己的 window.name。如果 targetUrl 是任意外部网站,它的页面根本没写 window.name,那你这个 readCrossDomainName 读回来只会是空字符串,拿不到任何有意义的内容。也就是说,iframe 方案解决的是“跨域读取技术通道”问题,数据源配套逻辑必须双方一起约定。
3.3 场景三:多级跨域链路里传递“接力棒”
还有一种需求是页面 A 调到域名 B,域名 B 短暂处理完又调到域名 C,域名 C 还需要知道最开始的业务入口信息。这种多级跨域链路用 URL 参数虽然可以逐级传,但每一级都要做一次参数透传,服务端网关还容易把参数吞掉。window.name 在这里可以当一个不需要逐级声明参数的接力棒。
实际操作里,A 页面写入数据后跳转 B,B 页面读取数据后如果不消费,就不要调用 take,只调用 NameStore.get 读取出来放到页面上展示,然后继续跳到 C。由于 window.name 在同一个标签页里始终没有变,C 页面读取时同样能看到最初 A 写入的数据。这样一个跨了三级的临时数据传递,中间页面不需要知道自己具体要透传什么参数,只要保证不清理 window.name 就行。
不过这里面有一个很容易出错的点:如果 B 页面还要传递自己的新数据给 C,那就不能只读不写,而应该把原有数据和新增数据合并后再写回。我见过有人直接在 B 页面里调用了 NameStore.set('PAGE_SOURCE', 'newValue'),结果底层 parse 之后把原来 A 写入的字段保留着,再新增 PAGE_SOURCE,最后 C 页面读到的数据里同时有 A 和 B 的两份信息。这个行为本身没有错
