1. 从一次“headers already sent”报错说起很多人第一次真正理解 PHP 页面跳转不是在教程里而是在一个登录页上——点击提交后页面白了一片最上面躺着一行黄色的警告Warning: Cannot modify header information - headers already sent by (output started at /var/www/html/login.php:1) in /var/www/html/check.php on line 12明明代码里写的是header(Location: index.php);看起来毫无破绽为什么就是跳不过去这个问题我前后帮人排查过不下十次答案往往出奇地一致文件开头多了一个看不见的 BOM或者?结束标签后面多了一个空行。要讲清楚这个问题得先回到 HTTP 本身。浏览器和服务器之间的对话其实非常“死板”服务器返回的内容必须严格分成两部分先是响应头告诉你状态码是多少、内容类型是什么、要不要跳到别的地址然后是一个空行再往后才是响应体真正的 HTML、JSON、图片数据。响应头必须在响应体之前一次性发出去一旦有任何字节——哪怕是一个空格、一个换行——先被送出去了PHP 就会认为“头已经发完了”此时再调用header()它只能给你一句警告然后什么也不做。所以 PHP 的页面跳转本质上不是 PHP 在跳而是 PHP 请求浏览器去跳。服务器返回一个“去别处拿”的指令浏览器看到指令后重新发起一次请求用户才看到新页面。理解这一点后面所有的坑和选择都会变得顺理成章。在 PHP 里实现这个指令通常有三条路它们的执行位置、可控程度和适用场景完全不同方式执行位置本质能否带状态码典型场景header(Location: ...)服务端返回 3xx Location 响应头能登录后跳转、权限拦截、表单处理完重定向meta http-equivrefresh浏览器解析 HTML让浏览器延时刷新到新地址不能已经有输出、需要倒计时提示页window.location浏览器执行 JS脚本层面修改地址栏不能前端拿到接口结果后决定去哪三者的优先级、可控性和“专业程度”是递减的。服务端能搞定的事尽量别交给浏览器只有当响应头已经发出去、或者业务上确实需要“先显示一句话再跳”时才退而求其次。很多老项目里一堆meta refresh并不是因为它更好而是因为写它的人当时被headers already sent卡住了索性放弃了正规做法。还有一个新手特别容易误解的点header(Location: xxx)后面的代码仍然会继续执行。PHP 不会因为你写了一句跳转就停下脚本它只是往响应头里塞了一行指令。如果你在跳转后面还写了写数据库、发邮件、更新日志的代码它们统统会跑完只是浏览器已经不理这个响应了。这就是后面要反复强调的那个exit。2. header(Location: ...) 的语法细节与三个高频翻车点header()是三种方式里最“正统”的一种用对了它干净、快速、对搜索引擎友好还能携带正确的状态码。但它的脾气也确实大几个细节没注意到就直接失效。2.1 写法本身没有玄机玄机在冒号后面最基础的形态是这样?php header(Location: https://www.example.com/dashboard.php); exit;注意几个点。冒号后面建议留一个空格虽然多数环境不加也能工作但按 HTTP 规范Location: /a.php才是标准写法。地址部分强烈建议写完整的绝对地址带协议和域名虽然现在主流浏览器都接受/a.php这样的相对路径但规范要求 Location 必须是绝对 URI某些老客户端和部分抓取工具对相对路径处理不一致跨域场景下更是必须写全。如果目标地址带查询参数一定要做编码不要用字符串拼接?php $params [ from login, next /order/list, ts time(), ]; $url https://www.example.com/index.php? . http_build_query($params); header(Location: . $url, true, 302); exit;http_build_query()会自动处理中文、空格、等字符的转义比手动拼?a1b2稳得多。如果你的参数里有用户可控的内容比如跳回来时的next还得额外做一层白名单校验这一点放在后面第 5 节展开。2.2 翻车点一任何一个字节的输出都会让 header() 失效这是出现频率最高的问题来源五花八门文件被编辑器保存成了UTF-8 with BOMBOM 是三个字节EF BB BF它会在 PHP 代码执行前就被输出文件末尾的?后面跟了空行或空格某个include进来的文件在?php之前有空白代码里echo了一个调试变量忘了删文件前面的注释块外面多敲了一个回车。排查这类问题别靠肉眼盯着看用 PHP 自带的函数直接问它?php if (headers_sent($file, $line)) { die(响应头已经在 {$file} 的第 {$line} 行发出去了去那里找多余的输出); }headers_sent()的两个参数是引用传递会把“第一次输出发生的位置”填进去定位效率比自己翻文件高太多。我一般会在项目入口文件的最顶上放这么一段检查只在开发环境开启。关于?结束标签PHP 官方文档其实早就建议纯 PHP 文件不要写结束标签。PSR 系列规范也是这个态度。少了它就少了一大类“文件尾部空白被输出”的坑。2.3 翻车点二忘了 exit后面的逻辑照跑不误看这段代码?php session_start(); if (empty($_SESSION[uid])) { header(Location: /login.php); } // 下面这些代码依然会执行 $orderList $db-query(SELECT * FROM orders WHERE uid . $_SESSION[uid]); foreach ($orderList as $row) { // 处理订单…… }没登录的用户会话里没有uidSQL 会拼接出一个不完整的条件轻则报错重则把不该给的数据查出来——虽然浏览器因为 302 不会展示但如果这段逻辑有副作用写文件、扣库存、发通知后果是实打实的。正确做法只有两个字exit。或者die()二者在语言层面是等价的。规范一点的项目会用exit()明确表示这是函数调用?php if (empty($_SESSION[uid])) { header(Location: /login.php); exit(); }有人会问那如果跳转后面确实还有收尾逻辑要跑呢比如记录一条审计日志。答案是把收尾逻辑写在exit之前或者用register_shutdown_function()注册一个关机回调让它在脚本结束包括 exit时执行。顺序搞反就是典型的逻辑事故。2.4 翻车点三地址拼错跳到 404 或者跳到错误目录相对地址的解析规则是按当前请求的 URL 来算的这一点非常反直觉。假设用户在https://example.com/shop/admin/user.php这个地址你写了header(Location: list.php);浏览器会解析成https://example.com/shop/admin/list.php而不是你想要的/list.php。多层目录的模块里这种“跳一层少一层”的问题特别常见。规避方式很朴素但很有效统一用根路径开头的地址/admin/list.php或者干脆维护一个站点根域名常量?php define(SITE_URL, https://www.example.com); header(Location: . SITE_URL . /admin/list.php); exit();这样做还有个额外好处换域名、上 CDN、切 HTTPS 的时候只要改一处常量全站跳转地址跟着走不用满项目搜Location:。3. meta refresh 与 window.location什么时候它们才是合理选择很多人觉得这两种方式“不正规”其实要看场景。有几种情况下服务端的header()根本用不了这时候用它们不是妥协而是唯一解。3.1 已经有输出时meta refresh 是成本最低的补救假设你接手了一个老项目页面顶部已经有整块 HTML 被输出或者模板引擎已经渲染了一半此时header()必然失效。你又不想大改架构最省事的做法是在head里插入meta http-equivrefresh content3;urlhttps://www.example.com/new-page.htmlcontent里的数字是延迟秒数0表示立即跳。写成3就是停留 3 秒配合一句“页面将在 3 秒后自动跳转如果没有反应请点击这里”体验上反而比瞬间跳走更友好——这也是为什么支付回调、系统升级公告这类页面偏爱它。需要留意的细节有两个一是 URL 里的在 HTML 属性中最好写成amp;否则严格的 HTML 校验会报错虽然浏览器一般能容错二是 meta refresh 只在head里生效塞在body里能不能触发各浏览器行为不一致别赌。3.2 location.href、location.assign、location.replace 的区别前端 JS 跳转写法上有三个非常接近的兄弟行为却不一样// 方式一产生一条新历史记录用户点返回能回到当前页 window.location.href /target.php; // 方式二和 href 等价语义更明确 window.location.assign(/target.php); // 方式三替换当前历史记录返回键回不到这一页 window.location.replace(/target.php);区别就在浏览器历史记录上。用href跳转用户按返回键会回到跳转前的页面用replace跳转前的页面被“顶掉”了返回键直接回到再上一页。登录成功后、注册成功后这类“不允许回头”的场景用replace更合适能避免用户退回到已提交的表单页造成重复提交。而普通的功能跳转用href保留返回路径更符合预期。3.3 用 JS 跳转时最大的风险是拼字符串这是必须单独拿出来说的一条。下面这段代码是典型的危险写法?php $next $_GET[next] ?? /index.php; ? script window.location.href ?php echo $next; ?; /script如果攻击者构造next;alert(document.cookie);//这样的参数你的页面就会执行他塞进来的脚本。哪怕只是跳转也不能把用户输入直接写进 JS 上下文。正确做法是先在 PHP 端做白名单校验再用json_encode()输出成 JS 能安全识别的字符串?php $allow [/index.php, /user/center.php, /order/list.php]; $next in_array($_GET[next] ?? , $allow, true) ? $_GET[next] : /index.php; ? script window.location.replace(?php echo json_encode($next, JSON_UNESCAPED_UNICODE); ?); /scriptjson_encode会处理引号、反斜杠、换行把内容变成一个合法的 JS 字符串字面量这是前端输出用户数据的通用防线。至于为什么用白名单而不是黑名单过滤一句话黑名单永远列不全白名单列错了最多是跳不过去不会出安全事故。顺带说一个更隐蔽的问题——开放重定向。如果next参数不做限制攻击者可以构造nexthttps://bad-site.example的链接用户看到的域名是你的跳过去却到了别人的站这种链接经常被用来做钓鱼。凡是“跳回来源页”的功能都必须校验目标是不是自家域名。3.4 什么时候必须选它们把结论摆出来更清楚必须用 meta 或 JS响应头已经发出、需要给用户一句解释再跳、需要在跳转前执行前端埋点或表单校验。优先用 header()登录鉴权、权限不足拦截、表单处理完成后的重定向、所有接口层跳转。还有一个折中方案值得提一句如果你只是想在页面上显示一句话再跳完全可以先ob_start()打开输出缓冲页面内容先存在缓冲区里不发给浏览器等需要跳转时清空缓冲区ob_end_clean()再调header()。这样既保留了“提示语”的需求又用上了正规跳转。第 6 节会详细讲缓冲区的用法。4. 状态码不是随便写的301、302、303、307 的区别与后果header(Location: ...)如果不指定状态码PHP 默认发302。绝大多数人从没管过这个默认值绝大多数时候也没出问题——但只要踩过一次代价往往不小。4.1 301 的缓存会把错误“刻”在浏览器里301 表示永久重定向浏览器和中间缓存可以把它记住。听起来是个性能优化实际上是一把双刃剑。我曾经见过一个事故开发同学在做站内跳转时顺手写了header(Location: /new.php, true, 301)上线后发现新页面有问题需要回滚结果大量用户的浏览器已经把这条 301 缓存下来了即使服务器端改回 302 或者删掉跳转用户访问旧地址依然被本地缓存直接送到新地址只能让用户手动清缓存。经验法则任何可能变化的跳转一律用 302只有域名整体迁移、URL 结构永久性变更这种“确定不会回头”的场景才用 301。4.2 POST 之后的跳转PRG 模式表单提交后直接输出结果页用户一刷新浏览器会提示“是否重新提交表单”处理不当会造成重复下单、重复发帖。行业里成熟的解法叫PRGPost / Redirect / Get接收 POST 请求的脚本处理完数据后立刻用 302 跳到另一个 GET 地址去展示结果。?php // submit.php —— 只负责处理不负责展示 if ($_SERVER[REQUEST_METHOD] POST) { $orderId createOrder($_POST); // 处理完就跳让浏览器重新发一个 GET 请求 header(Location: /order/detail.php?id . (int)$orderId, true, 303); exit(); }这里用的是303 See Other它比 302 更精确地表达了一件事不管刚才是什么请求方法下一个请求必须是 GET。历史上很多浏览器和框架对 302 的处理是“把 POST 改成 GET”虽然实用但语义模糊333 这个状态码就是为此专门标准化出来的。做 PRG 的时候写 303语义最干净。4.3 307 和 308需要保住请求方法时另外两个不太常见但有意义的状态码307 Temporary Redirect临时的且明确要求保留原请求方法。用 POST 请求 A 地址被 307 重定向到 B 地址客户端会用 POST 再请求 B而不是变成 GET。308 Permanent Redirect永久的同时保留请求方法可以理解为“301 保留方法”的组合。什么时候用得上接口层面的地址变更、API 网关转发、需要把带 body 的请求整体挪到新端点时。普通的页面跳转基本用不到它们。状态码语义请求方法会不会被缓存适用场景301永久可能被改成 GET会被长期缓存域名迁移、URL 永久变更302临时实践中常变成 GET一般不缓存登录跳转、日常功能跳转303临时强制 GET强制变 GET一般不缓存表单提交后的 PRG307临时保留方法保留一般不缓存接口地址临时变更308永久保留方法保留会被长期缓存接口地址永久变更回到最常被问到的问题要不要手动写状态码我的建议是日常跳转显式写302做 PRG 写303域名迁移写301其余保持默认。把状态码写出来最大的价值不是给浏览器看而是给三个月后接手代码的同事看——他一眼就知道这个跳转是什么性质。顺带说一个和 SEO 相关的小结论站点改版、换域名时用 301 才能把老页面的权重传递到新页面用 302 或者 meta refresh、JS 跳转搜索引擎基本不认老页面该掉排名还是掉。这是运营同学经常来问的问题技术上其实就是一行状态码的差别。5. 把跳转逻辑收拢成一个函数几种实用封装项目里如果到处散落着header(Location: ...)早晚会出现“有的写了 exit有的没写”“有的用相对路径有的用绝对路径”的混乱局面。我的习惯是在项目初期就封装一个统一的跳转函数把容易出错的细节一次性关掉。5.1 一个够用的通用实现?php /** * 安全跳转 * * param string $url 目标地址支持站内路径或完整 URL * param int $code 状态码默认 302 */ function redirect(string $url, int $code 302): void { // 1. 清理可能存在的输出缓冲避免 header 被污染 if (ob_get_level() 0) { ob_end_clean(); } // 2. 防止响应头注入地址里不允许出现换行符 $url str_replace([\r, \n, %0d, %0a], , $url); // 3. 站内相对路径补全为绝对地址 if (strpos($url, http://) ! 0 strpos($url, https://) ! 0) { $url rtrim(SITE_URL, /) . / . ltrim($url, /); } // 4. 头已经发出去就只能退回前端跳转 if (headers_sent()) { echo scriptwindow.location.replace( . json_encode($url, JSON_UNESCAPED_UNICODE) . );/script; exit(); } header(Location: . $url, true, $code); exit(); }这段代码里有四个细节值得说明。第一ob_end_clean()。如果页面之前开过输出缓冲缓冲区里可能已经攒了一些 HTML这些内容还没发给浏览器此时清掉它header()就依然有效。这是“页面已经渲染了一部分但还是想走正规跳转”的救命手段。第二换行符过滤。HTTP 响应头是按行组织的如果 URL 里混进了\r\n攻击者就能伪造额外的响应头这叫 HTTP 响应头注入CRLF Injection。虽然现代 PHP 版本已经会在header()里拦截换行符但自己再加一道防线不亏。第三相对路径补全。统一转成绝对地址彻底避免“多层目录下相对路径解析错位”的问题这是前面第 2.4 节那个坑的根治办法。第四headers_sent()兜底。头已经发出去时优雅地降级到 JS 跳转而不是给用户抛一个警告。这样调用方完全不用关心当前处于什么状态函数自己会挑合适的方式。5.2 跳转前记得关掉会话锁这是一个很少有人提、但在并发场景下真实存在的问题。PHP 的会话默认是基于文件的脚本开始执行时会独占锁住会话文件直到脚本结束才释放。如果你的跳转脚本后面还挂着耗时操作写大文件、调外部接口、发邮件用户点下按钮后要等这些操作跑完才会看到新页面显得“卡住了”。解法是在跳转前主动释放会话?php session_start(); $_SESSION[last_login] time(); // 数据已经写好了主动释放锁让同一用户的后续请求不用排队 session_write_close(); header(Location: /dashboard.php, true, 302); exit();更重要的是那些耗时的后续操作不应该放在跳转前同步执行扔进消息队列让后台慢慢跑才是正解。跳转这件事本身就应该是毫秒级的。5.3 不同框架里的等价写法如果你用的是框架很多细节已经被底层处理过了但知道它对应的是什么很有必要场景原生写法常见框架约定跳转任意地址header(Location: /a.php); exit;Laravel 的return redirect(/a);跳到命名路由手动拼 URLLaravel 的return redirect()-route(user.center);带一次性提示自己塞进 session框架的with(msg, 保存成功)闪存机制带参数跳回http_build_queryThinkPHP 的$this-redirect(index/index, [id 1]);成功提示后跳meta / JSThinkPHP 的$this-success(操作成功, index/index);ThinkPHP 那套success()/error()的底层实现挺有意思它是先渲染一个带倒计时的跳转页也就是 meta refresh 或 JS因为提示语已经输出到页面上了服务端跳转此时已经不可能生效。所以你在框架里看到“跳转提示页”本质上就是本文第 3 节的场景。另外注意一点如果框架的跳转方法已经自动做了exit你在后面补的代码永远不会执行。调试时遇到“明明跳转了但日志没写”先确认是不是这个原因。5.4 AJAX 请求里的跳转为什么“没反应”这是前后端配合时很典型的困惑后端接口检测到登录过期执行了header(Location: /login.php)前端发起的fetch请求却什么都没发生控制台也不报错。原因在于fetch 和 XMLHttpRequest 遇到 3xx 重定向时不会去修改浏览器的地址栏它们只是悄悄地跟着 Location 再发一次请求然后把最终返回的内容这里是登录页的 HTML当成数据交给你。前端代码以为拿到的是 JSON结果解析失败或者干脆一片沉默。AJAX 场景下的正确做法是后端不跳转返回一个结构化的 JSON由前端决定怎么跳。?php // 接口侧 if (empty($_SESSION[uid])) { header(Content-Type: application/json; charsetutf-8); echo json_encode([ code 401, message 登录已过期, redirect /login.php?next . urlencode($_SERVER[REQUEST_URI]), ], JSON_UNESCAPED_UNICODE); exit(); }// 前端侧 const res await fetch(/api/order/list); const data await res.json(); if (data.code 401) { // 由前端控制跳转历史记录可控也能先弹个提示 window.location.replace(data.redirect); }这套约定要写进团队的前端请求封装里统一处理否则每个页面都得自己判断一遍漏掉一个就是一个白屏 bug。这也是我在做前后端分离项目时最先定下来的接口规范之一。6. 那些容易忽略的边界缓冲区、CLI、爬虫与排查清单写跳转代码的时间可能只占全部工作量的 5%但它出问题的地方特别分散。除了前面讲过的还有几个边界值得单独拎出来。6.1 输出缓冲区是“后悔药”但别乱吃PHP 的输出缓冲output buffering机制可以让echo出的内容先在内存里待着不立刻发给浏览器。这带来的直接好处是即使你已经echo了一堆 HTML只要缓冲区还没刷出去header()依然可用。?php ob_start(); // 开启缓冲 echo p正在处理你的请求……/p; if ($needRedirect) { ob_end_clean(); // 丢掉缓冲区内容当作什么都没输出过 header(Location: /target.php, true, 302); exit(); } ob_end_flush(); // 不需要跳转正常输出要点在于ob_end_clean()和ob_end_flush()必须成对考虑。开了缓冲却忘了关闭内容可能一直被压在内存里直到脚本结束才吐出配合Content-Length之类的响应头还可能出问题。另外缓冲区是可以嵌套的用ob_get_level()判断当前有几层比自己记靠谱。还有一个坑php.ini里的output_buffering配置项开发环境常常是4096之类的值意味着默认就开着缓冲。这会导致一个诡异的现象——在本机测试跳转一切正常部署到线上就报headers already sent因为线上关掉了缓冲。所以本地测试的时候最好显式在代码里ob_start()别依赖环境的默认配置。6.2 命令行环境里没有响应头这回事用 PHP 写定时任务、数据迁移脚本时别习惯性地带上跳转逻辑。CLI 模式下没有 HTTP 协议也就没有响应头header()调用不会生效也不会报错只是被忽略headers_sent()的返回值也和 Web 环境不一样。如果你把 Web 入口和 CLI 入口共用同一段业务代码记得提前判断运行环境?php if (PHP_SAPI ! cli) { header(Location: /done.php); exit(); } // 命令行下继续跑批处理逻辑PHP_SAPI这个常量在 Web 环境下是fpm-fcgi、apache2handler之类的值在命令行下是cli用它做分支判断是最直接的方式。6.3 跳转不生效时的排查顺序踩坑多了就沉淀出一套固定的排查顺序从成本最低的开始试看报错。有没有headers already sent的警告有的话直接看它给出的文件名和行号。确认 exit。跳转语句后面有没有exit()或die()没有就先加上这是唯一一个不排查都该改的问题。查 BOM 和空白。用编辑器把文件编码切成“UTF-8 无 BOM”另存一次顺手删掉所有 PHP 文件的结束标签和尾部空行。查路径。把跳转地址临时换成完整的https://你的域名/xxx.php试一次能跳说明是相对路径解析问题。查状态码。用浏览器开发者工具的网络面板看这条请求的响应码。如果是 301 且结果不对八成是浏览器缓存了旧的重定向换个隐身窗口验证。查执行环境。是不是在 CLI 下跑的是不是在框架的生命周期里被中间件改写了是不是在 AJAX 请求里期待地址栏变化查缓冲区。是不是某个全局前置文件开了ob_start()但中途清空了导致前面积累的内容被意外发送这套顺序的价值在于它大部分时候在第 2、3 步就能结束战斗不需要你去翻框架源码。6.4 一点关于“体验”的额外想法跳转这件事虽然只有一行代码但它直接决定用户点下按钮之后看到什么。我的几个习惯做法第一所有需要时间等待的跳转给一句提示语哪怕是 meta refresh 也要写“正在跳转请稍候”空白页最容易让人以为系统崩了第二登录后的跳转目标要记得带上next参数并做白名单校验用户从收藏夹点进来被要求登录登完却回到首页体验会很差第三接口层的跳转信号统一用 JSON 表达别让前端去猜后端到底跳没跳。我在实际项目里踩过最难受的一次是一个限时活动页用了 301 跳转到落地页活动结束后想换回原页面做归档结果一批老用户的浏览器死活不认新配置最后只能在原地址上叠加一层 302 才勉强绕过去。从那以后凡是带有“可能还会改”性质的跳转我一律写 302从不图那点缓存优化的便宜。