你点了「登录」之后,浏览器是怎么记住你刚才在看什么的?

11 阅读6分钟

你在网站上浏览商品详情页,点击"加入购物车"时发现未登录,页面跳转到登录页。输入账号密码后,神奇的事情发生了——页面自动跳转回了你刚才看的那个商品,筛选条件、颜色、尺码都还在。这个"跳转回来"是怎么实现的?

这个看似简单的功能,背后涉及 URL 编码、参数传递、安全性验证等多个技术细节。我在实现类似功能时踩过不少坑:URL 参数莫名丢失、中文出现乱码、特殊字符导致的跳转失败。这篇文章是我重新梳理这些细节的思考过程。

问题的起源:为什么 URL 需要特殊处理?

URL 的双重身份

在深入技术细节前,我想先聊聊 URL 的本质。我们通常把 URL 理解为"网址",但它其实有两个身份:

身份1:地址(Address)

  • 指向资源的位置
  • 例如:https://example.com/products/123

身份2:数据容器(Data Container)

  • 通过 query parameters 携带状态
  • 例如:https://example.com/search?keyword=React&page=2

正是第二个身份,让 URL 成为前端数据传递的重要载体。但这也带来了一个问题:不是所有字符都能"安全"地出现在 URL 中。

字符的"合法性"问题

让我们从一个简单的例子开始:

// Environment: Browser
// Scenario: What happens with this URL?

const keyword = "前端 开发";
const url = `https://api.com/search?keyword=${keyword}&type=article`;

console.log(url);
// https://api.com/search?keyword=前端 开发&type=article

这段代码看起来没问题,但实际上隐藏着几个问题:

  1. 空格会被如何处理? 浏览器可能把空格当作 URL 的结束
  2. 中文字符在 URL 中合法吗? 根据规范,URL 只能包含 ASCII 字符
  3. 如果搜索词包含 & 呢? 比如搜索 "React & Vue",& 会被误认为是参数分隔符

URL 规范的限制

根据 RFC 3986 规范,URL 有一些硬性约束:

约束1:只能包含 ASCII 字符

  • 中文、emoji 等非 ASCII 字符需要编码
  • 这是历史原因:URL 规范制定于上世纪 90 年代,当时互联网主要使用英文

约束2:某些字符有特殊含义

  • ? 标记 query string 的开始
  • & 分隔多个参数
  • = 连接参数名和值
  • # 标记 fragment(锚点)
  • / 分隔路径

约束3:其他字符需要百分号编码

  • 非法字符会被转换为 % + 十六进制
  • 例如:空格 → %20,中文"前端" → %E5%89%8D%E7%AB%AF

这让我开始思考:为什么 URL 要设计得这么"麻烦"?不能直接支持中文吗?答案是兼容性——如果改变 URL 规范,整个互联网的基础设施都要跟着改。

核心概念探索:URL 编码的几个层次

encodeURIComponent:你最常用的编码函数

这是我们在日常开发中最常用到的编码函数。

// Environment: Browser / Node.js
// Scenario: Encode URL parameter value

const searchKeyword = "前端开发 & Vue.js";
const encoded = encodeURIComponent(searchKeyword);

console.log(encoded);
// Output: %E5%89%8D%E7%AB%AF%E5%BC%80%E5%8F%91%20%26%20Vue.js

// Use in URL
const url = `https://api.com/search?q=${encoded}`;
console.log(url);
// https://api.com/search?q=%E5%89%8D%E7%AB%AF%E5%BC%80%E5%8F%91%20%26%20Vue.js

它做了什么?

  1. 将字符转换为 UTF-8 编码的字节序列
  2. 每个字节用 % + 两位十六进制表示
  3. 例如:汉字"前" → UTF-8: 0xE5 0x89 0x8D%E5%89%8D

哪些字符会被编码?

  • 保留字符:; , / ? : @ & = + $ #
  • 非 ASCII 字符:中文、日文、emoji 等
  • 不安全字符:空格、< > " { }

哪些字符不会被编码?

  • 字母:A-Z a-z
  • 数字:0-9
  • 特殊字符:- _ . ! ~ * ' ( )

encodeURI vs encodeURIComponent:容易混淆的兄弟

这两个函数的名字很像,但用途完全不同,混用会导致严重问题。

// Environment: Browser
// Scenario: Compare the two encoding functions

const url = "https://example.com/search?q=hello world&type=article";

console.log(encodeURI(url));
// https://example.com/search?q=hello%20world&type=article
// Notice: protocol, domain, ?, &, = are preserved

console.log(encodeURIComponent(url));
// https%3A%2F%2Fexample.com%2Fsearch%3Fq%3Dhello%20world%26type%3Darticle
// Notice: even :// ? & = are encoded

使用场景区分

函数编码范围适用场景典型用法
encodeURI不编码 URL 结构字符编码整个 URLencodeURI('https://example.com/搜索')
encodeURIComponent编码所有特殊字符编码 URL 参数值encodeURIComponent('搜索&排序')

常见错误示例

// Environment: Browser
// Scenario: Common mistake when choosing encode function

// ❌ Wrong: Using encodeURI for parameter value
const keyword = "type=article";
const wrongUrl = `https://api.com/search?q=${encodeURI(keyword)}`;
console.log(wrongUrl);
// https://api.com/search?q=type=article
// Problem: = is not encoded, causes parameter parsing error

// ✅ Correct: Using encodeURIComponent
const correctUrl = `https://api.com/search?q=${encodeURIComponent(keyword)}`;
console.log(correctUrl);
// https://api.com/search?q=type%3Darticle

我踩过的坑就是用 encodeURI 编码参数值,导致参数解析错误。这个 bug 排查了半天才发现问题。

思考点:为什么需要两个函数?我的理解是,这反映了两种不同的使用场景——有时我们需要编码整个 URL(比如作为另一个 URL 的参数),有时只需要编码参数值。

解码:decodeURIComponent

编码后的数据,需要解码才能使用:

// Environment: Browser
// Scenario: Read and decode URL parameters

const urlParams = new URLSearchParams(window.location.search);
const keyword = urlParams.get('q');

// URLSearchParams automatically decodes
console.log(keyword); // "前端开发 & Vue.js"

// Manual decoding
const encoded = "%E5%89%8D%E7%AB%AF";
const decoded = decodeURIComponent(encoded);
console.log(decoded); // "前端"

常见陷阱1:重复编码

// Environment: Browser
// Scenario: Double encoding trap

const keyword = "前端";
const encoded1 = encodeURIComponent(keyword);
console.log(encoded1); // %E5%89%8D%E7%AB%AF

const encoded2 = encodeURIComponent(encoded1); // Encoded again!
console.log(encoded2);
// %25E5%2589%258D%25E7%25AB%25AF
// Notice: % itself is encoded as %25

// Must decode twice
const result = decodeURIComponent(decodeURIComponent(encoded2));
console.log(result); // "前端"

这个问题在框架自动处理编码时很容易出现——你手动编码了一次,框架又编码了一次,结果服务器收到的是双重编码的数据。

常见陷阱2:+ 号的特殊性

// Environment: Browser
// Scenario: Plus sign special case

const keyword = "C++ programming";
const encoded = encodeURIComponent(keyword);
console.log(encoded); // C%2B%2B%20programming

// Some servers interpret + as space
// May result in: "C   programming" (with extra spaces)

某些服务器(特别是使用 application/x-www-form-urlencoded 格式的)会把 + 解释为空格,这是历史遗留问题

URLSearchParams:现代化的解决方案

手动拼接 URL 参数容易出错,现代浏览器提供了更安全的方案:

// Environment: Modern browsers (not supported in IE)
// Scenario: Build URL with parameters

// Method 1: Manual concatenation (error-prone)
const keyword = "前端 & Vue";
const url1 = `https://api.com/search?q=${encodeURIComponent(keyword)}&page=1`;

// Method 2: Using URLSearchParams (recommended)
const params = new URLSearchParams({
  q: keyword,  // Automatically encoded
  page: 1,
  sort: 'date'
});

const url2 = `https://api.com/search?${params.toString()}`;
console.log(url2);
// https://api.com/search?q=%E5%89%8D%E7%AB%AF+%26+Vue&page=1&sort=date

URLSearchParams 的优势

  • 自动处理编码/解码
  • 更清晰的 API
  • 避免手动拼接的错误

局限性

  • 空格会被编码为 + 而非 %20(这在大多数场景下没问题)
  • IE 不支持(需要 polyfill)

我的建议是:在新项目中尽量使用 URLSearchParams,它能避免 90% 的编码问题。

实际场景思考:登录跳转的 redirect 参数

让我们用一个完整的场景,把上面的知识串起来。

场景描述

用户正在浏览商品详情页 /product/123?color=red&size=L,点击"加入购物车"时发现未登录,跳转到登录页。登录成功后,应该回到刚才的商品页,并且保持筛选条件(颜色和尺码)。

方案1:直接拼接(错误示范)

// Environment: React / Vue SPA
// Scenario: Navigate to login page with current URL

function navigateToLogin() {
  const currentUrl = window.location.href;
  
  // ❌ Wrong: Direct concatenation, no encoding
  window.location.href = `/login?redirect=${currentUrl}`;
}

// Problem: currentUrl contains ? and &
// Result: /login?redirect=http://example.com/product/123?color=red&size=L
// Server will treat color and size as login page parameters!

这是我第一次实现这个功能时犯的错误。结果是用户登录后跳转到了错误的页面,因为参数被错误解析了。

方案2:编码 redirect 参数(正确做法)

// Environment: React / Vue SPA
// Scenario: Correctly carry redirect URL

function navigateToLogin() {
  const currentUrl = window.location.pathname + window.location.search;
  
  // ✅ Correct: Encode redirect parameter
  const encodedRedirect = encodeURIComponent(currentUrl);
  window.location.href = `/login?redirect=${encodedRedirect}`;
}

// Result: /login?redirect=%2Fproduct%2F123%3Fcolor%3Dred%26size%3DL
// Server can correctly parse it

方案3:使用 URLSearchParams(更优雅)

// Environment: Modern browsers
// Scenario: Build login URL using URLSearchParams

function navigateToLogin() {
  const currentPath = window.location.pathname + window.location.search;
  
  const loginParams = new URLSearchParams({
    redirect: currentPath  // Automatically encoded
  });
  
  window.location.href = `/login?${loginParams.toString()}`;
}

登录成功后的跳转

// Environment: Login page
// Scenario: Redirect after successful login

function handleLoginSuccess() {
  // Read redirect parameter
  const urlParams = new URLSearchParams(window.location.search);
  const redirect = urlParams.get('redirect');
  
  if (redirect) {
    // URLSearchParams already decoded automatically
    window.location.href = redirect;
  } else {
    // No redirect parameter, go to default page
    window.location.href = '/';
  }
}

安全性考虑:开放重定向漏洞

这里有一个很容易被忽视的安全问题。

问题场景:恶意用户可能构造这样的 URL:

https://yoursite.com/login?redirect=https://phishing.com

如果你的代码没有验证,用户登录后会被跳转到钓鱼网站!

解决方案

// Environment: Login page
// Scenario: Validate redirect parameter for security

function handleLoginSuccess() {
  const urlParams = new URLSearchParams(window.location.search);
  const redirect = urlParams.get('redirect');
  
  if (redirect) {
    // Security check 1: Only allow relative paths
    if (redirect.startsWith('/') && !redirect.startsWith('//')) {
      window.location.href = redirect;
      return;
    }
    
    // Security check 2: Only allow same-origin absolute URLs
    try {
      const redirectUrl = new URL(redirect);
      const currentOrigin = window.location.origin;
      
      if (redirectUrl.origin === currentOrigin) {
        window.location.href = redirect;
        return;
      }
    } catch (e) {
      // URL parsing failed, ignore
    }
  }
  
  // Unsafe redirect, go to default page
  window.location.href = '/';
}

为什么 //example.com 是危险的?

// This is a protocol-relative URL
const url = "//malicious.com";

// Browser will interpret it as:
// - https://malicious.com (if current page is HTTPS)
// - http://malicious.com (if current page is HTTP)

// So we need to check !redirect.startsWith('//')

这个细节我是在安全审计时才了解到的,之前从没想过 // 开头的 URL 会有这个问题。

完整示例:React Router 实现

// Environment: React Router v6
// Scenario: Complete login redirect flow

import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';

// Component: Protected page
function ProductDetail() {
  const navigate = useNavigate();
  const location = useLocation();
  
  const handleAddToCart = () => {
    if (!isLoggedIn()) {
      // Save current full path (including query params)
      const currentPath = location.pathname + location.search;
      const params = new URLSearchParams({
        redirect: currentPath
      });
      navigate(`/login?${params.toString()}`);
    } else {
      // Add to cart logic
      addToCart();
    }
  };
  
  return (
    <button onClick={handleAddToCart}>Add to Cart</button>
  );
}

// Component: Login page
function Login() {
  const navigate = useNavigate();
  const [searchParams] = useSearchParams();
  
  const handleLoginSuccess = () => {
    const redirect = searchParams.get('redirect');
    
    // Security validation
    if (redirect && redirect.startsWith('/') && !redirect.startsWith('//')) {
      navigate(redirect);
    } else {
      navigate('/');
    }
  };
  
  return (
    <form onSubmit={handleLoginSuccess}>
      {/* Login form */}
    </form>
  );
}

延伸与发散

OAuth 中的 redirect_uri

在第三方登录场景中,redirect_uri 是一个关键参数:

// Environment: OAuth 2.0 flow (e.g., GitHub OAuth)
// Scenario: Redirect to authorization page

const clientId = 'your_client_id';
const redirectUri = 'https://yoursite.com/auth/callback';

// Must encode redirect_uri
const authUrl = 
  `https://github.com/login/oauth/authorize?` +
  `client_id=${clientId}&` +
  `redirect_uri=${encodeURIComponent(redirectUri)}`;

window.location.href = authUrl;

OAuth 的严格要求

  • redirect_uri 必须与注册时填写的完全一致(包括协议、端口)
  • 某些平台要求必须是 HTTPS
  • 不能包含 fragment(# 之后的部分)

我曾经因为 redirect_uri 末尾多了一个斜杠 /,导致 OAuth 验证失败,排查了很久。这让我意识到 URL 的每个字符都很重要。

Query Params vs Hash vs Path:数据传递的三种方式

  1. Method 1: Query Parameters
  1. Method 2: Hash (Fragment)
  1. Method 3: Path Parameters

选择建议

  • 服务端需要的数据 → Query Params 或 Path
  • 纯前端状态(如 modal 开关)→ Hash
  • 资源标识(如用户 ID)→ Path

为什么 React Router 默认使用 Hash 模式?因为 Hash 不会发送到服务器,避免了服务端路由配置的麻烦。但现在更推荐 History 模式,因为 URL 更美观。

URL 长度限制

不同浏览器和服务器对 URL 长度有限制:

环境限制
Chrome约 2MB
IE2083 字符
Nginx默认 4K-8K
Apache默认 8K

思考点:如果需要传递大量数据怎么办?

几种替代方案:

  1. 使用 POST 请求,数据放在 body 中
  2. 先将数据存储到服务器,只在 URL 中传递 ID
  3. 使用 LocalStorage + BroadcastChannel 在客户端传递

我在做一个复杂筛选功能时遇到过这个问题,最后的方案是把筛选条件存到 LocalStorage,URL 中只传递一个筛选 ID。

待探索的问题

在梳理这些知识时,我产生了一些新的疑问:

  1. 单页应用的 URL 状态管理:如何在 React/Vue 中优雅地处理 URL 参数?
  2. History API 与 URL 的关系pushStatereplaceState 如何配合 URL 参数使用?
  3. 可分享的 URL:如何实现包含完整筛选、排序状态的可分享链接?
  4. 服务端渲染中的 URL 传递:在 Next.js/Nuxt.js 中,URL 参数如何传递到组件?

小结

URL 编码看似简单,实际上涉及字符集、协议规范、安全性等多个层面。

核心收获

  1. 选对编码函数encodeURIComponent 用于参数值,encodeURI 用于整个 URL
  2. 安全第一:redirect 参数必须严格验证,防止开放重定向漏洞
  3. 拥抱现代 APIURLSearchParamsURL 对象让操作更安全便捷
  4. URL 是数据载体:理解 URL 不仅是地址,也是前端数据传递的重要方式

实践建议

  • 优先使用 URLSearchParams 而非手动拼接字符串
  • 对用户输入的 redirect 参数做严格的白名单验证
  • 注意 URL 长度限制,避免传递过多数据
  • 在开发环境测试各种边界情况(中文、特殊字符、超长 URL)

  • 你在项目中遇到过 URL 编码相关的 bug 吗?是怎么排查的?
  • 如果让你设计一个路由库,你会如何处理 URL 参数的编码问题?
  • 在什么场景下,URL 传参是不合适的?应该用什么替代方案?

参考资料