做小程序这几年,被问得最多的一个基础问题就是“当前页面的 url 和参数怎么拿”。听起来像个三分钟就能说完的小知识点,但真正落到项目里,你会发现它牵扯出的东西一点都不少:页面栈的层级关系、onLoad 与 onShow 的执行时序、分包页面的路由前缀、tabBar 页面为什么死活传不进参数、扫码进入时那串被编码过的 scene 到底怎么拆,甚至还包括分享转发时怎么把当前路径原样还原出来。围绕“微信小程序获取当前页面的 url 和参数”这一个点,我前后在四五个项目里反复填过坑,从最开始的getCurrentPages()一把梭,到后来封装成一套统一的 page 工具函数,中间踩过的雷基本把一个新手的成长路径走完了。
这篇内容我打算按真实项目的推进顺序来讲:先把“url”这个概念在小程序里掰开揉碎,再讲几个取值入口各自的边界,然后给出一套可以直接复制到utils目录里用的工具代码,最后把那些文档里不会写、但线上一定会遇到的坑列出来。不管你刚开始写第一个小程序,还是已经维护过一个几十个页面的中型项目,应该都能从里面捞到点能立刻用的东西。代码我会尽量给全,参数怎么传、怎么解、怎么防乱码,都会说清楚。
1. 小程序里的“当前页面 url”到底指什么
很多人第一反应会把小程序的 url 等同于浏览器里的location.href,觉得页面上有个现成的字符串可以读。这个认知偏差是后面一系列问题的源头,所以第一个章节必须先把概念对齐,不然后面写再多工具函数都是空中楼阁。
1.1 页面栈决定了 url 是一组数据而不是一个字符串
小程序是单页面多 WebView 的架构,每个打开过的页面都会进入一个叫页面栈的数组里,数组的顺序就是页面打开的先后顺序,栈顶那个就是用户当前看到的页面。getCurrentPages()这个全局函数返回的就是这个数组,它每一项是一个页面实例对象,里面带着这个页面的route(路由路径)、options(onLoad 时接收到的参数对象)以及页面自身的方法。
关键在于:url 不是一个被维护好的字符串,而是 route 和 options 两个字段拼出来的结果。route拿到的是类似pages/detail/index这样的路径,注意它不带开头的斜杠;options拿到的是{ id: '10086', from: 'share' }这样的普通对象。你想得到pages/detail/index?id=10086&from=share,得自己动手拼。这一层理解到位了,后面所有的取值、还原、校验逻辑才有落脚点。
另外要建立的一个直觉是:页面栈是有深度上限的。官方给的数字是10 层,用wx.navigateTo反复往下跳,超过之后就会失败并触发 fail 回调。这就意味着靠“一路 navigateTo 到底”的设计迟早会崩,后面第 5 章我会专门讲这个限制带来的连锁反应。
1.2 为什么不能像浏览器那样直接读 location
小程序里没有window对象,页面上的 JS 运行在一个被裁剪过的环境里,浏览器那套location、history、document全都不可用。你只能通过平台提供的 API 间接拿到当前路由信息。这一点经常让从 H5 转过来的同学卡壳,他们会本能地写window.location.href,结果直接报错或者拿到 undefined。
这里有个容易被忽略的细节:小程序的页面实例上没有公开的 url 属性。你在调试器里展开一个页面实例,可能会看到__displayReporter、__route__这类以下划线开头的字段,看起来像是能直接读。别碰,这些都是内部实现,基础库版本一升级就可能改名甚至消失,线上出问题的时候你连日志都打不出来。老老实实用公开 API 组合出结果,这一条是我用血泪换来的建议。
还有一点值得提前建立认知:options这个对象在页面实例上是可写的,也就是说你能改它,改了之后getCurrentPages()再读出来就是你改过的值。有些团队会利用这个特性在页面内更新参数状态,但这属于非官方用法,跨版本稳定性没法保证。我的做法是只读不写,需要更新参数就自己在页面 data 里维护一份,两套数据各司其职。
2. 三个取值入口与它们各自适用的场景
理解了 url 是拼出来的之后,接下来的问题就是“从哪儿拿”。小程序给了三个常见的入口:getCurrentPages()、页面生命周期的onLoad(options),以及wx.getEnterOptionsSync()。这三个不是互相替代的关系,各自适用的时机和覆盖面都不一样,混着用或者只用一个,都会留下盲区。
2.1 getCurrentPages 是通用主力,但有时机要求
getCurrentPages()最大的好处是随时可调,只要页面栈里有内容,你就能拿到当前页面的 route 和 options。它特别适合这几类场景:埋点上报时想知道用户当前在哪个页面;分享回调里需要把当前页面路径作为 path 传出去;全局的错误捕获里想记录出错页面;还有一些自定义导航栏组件里需要根据当前路由做高亮判断。
但它有个硬性的时机限制:调用时必须已经有页面实例入栈。在App.onLaunch里调它,返回的是空数组,因为它执行的时候第一个页面还没完成创建。这一点非常反直觉,很多人想在小程序启动时就上报一下入口路径,结果发现啥都拿不到。正确的做法是改用wx.getEnterOptionsSync(),或者把上报逻辑延后到首页的onShow里。
还有一个实际用的时候要留神的点:getCurrentPages()返回的数组每一项的 options 内容可能不一致。栈顶页面的 options 是它自己 onLoad 时收到的参数,但如果你是通过navigateBack回到某一页,那一页的 options 还是它当初打开时的老值,不会因为返回而刷新。这个特性直接导致了第 5 章要讲的“拿到旧参数”问题。
2.2 onLoad(options) 里的参数最干净
如果你想拿到当前页面被打开时真正传进来的参数,最可靠的来源其实是页面自己的onLoad生命周期回调。这个回调的第一个参数就是解析好的参数对象,平台已经帮你把 query string 拆好了,你不用自己写正则去 parse。而且这个对象是这次打开动作的快照,不会因为后续操作被污染。
它的局限也很明显:只在页面首次创建时触发一次。如果用户从 A 页跳到 B 页,再返回 A 页,A 页的 onLoad 不会再执行,你拿不到任何新东西。所以纯靠 onLoad 做参数驱动的页面逻辑,返回时是失效的。我的处理习惯是:onLoad里做一次初始化,把参数落到data里,同时挂一个标记;onShow里再按需做一次刷新逻辑,两者配合使用。
另外注意onLoad的参数值永远是字符串类型。?id=10086传进来的是字符串"10086",不是数字。如果这个 id 后面要用于数值比较或者数组索引,记得手动转一下类型,否则id === 10086恒为 false 这种问题能让你 debug 半小时。
2.3 wx.getEnterOptionsSync 负责冷启动与热启动的入口
wx.getEnterOptionsSync()是基础库 2.20.1 之后提供的同步接口,专门用来获取本次启动的入口信息,包括path、query、scene、referrerInfo等字段。它和getCurrentPages()是互补的:前者告诉你“用户是从哪儿进来的”,后者告诉你“用户现在在哪儿”。
这个接口在扫码进入、公众号菜单跳转、分享卡片打开这类场景里特别有用,因为你拿到的query是启动那一刻的完整参数,不受页面栈状态影响。要留意的是它返回的是启动时的快照,小程序被切到后台再切回来(热启动)时,应该改用wx.getEnterOptionsSync()重新读一次,或者在App.onShow(options)里接收参数,而不是复用启动时的旧值。
三个入口的分工我整理成一张表,方便对着查:
| 入口 | 可用时机 | 拿到什么 | 典型场景 |
|---|---|---|---|
getCurrentPages() | 页面栈非空时随时 | route + options,栈顶页面 | 埋点、分享、路由判断 |
onLoad(options) | 页面首次创建 | 本次打开的参数快照 | 页面初始化、数据请求 |
wx.getEnterOptionsSync() | 任意时刻 | 启动入口 path、query、scene | 扫码进入、渠道统计 |
3. 手写一套能直接抄的 url 工具函数
概念和入口都清楚了,接下来就是动手写。我在每个项目里都会在utils目录下放一个page.js,专门收敛这类取路由、拼参数、做校验的逻辑。好处是页面层代码干净,出了问题只改一个地方。下面这套代码我用了挺久,基础库 2.x 全版本都跑得通。
3.1 基础版:取当前页面的 route
先把最小可用的部分写出来。核心就三行:拿页面栈、取栈顶、读 route。加上空值保护,避免在极端情况下抛异常。
// utils/page.js /** * 获取当前页面的实例 * 页面栈为空时返回 null,调用方需要自行判断 */ function getCurrentPage() { const pages = getCurrentPages(); if (!Array.isArray(pages) || pages.length === 0) { return null; } return pages[pages.length - 1]; } /** * 获取当前页面的路由,不带前导斜杠 * 例如:pages/detail/index */ function getCurrentRoute() { const page = getCurrentPage(); if (!page) return ''; return page.route || ''; }这里要强调 route 的格式:它不带开头的斜杠,而wx.navigateTo的 url 参数需要带斜杠。这就是为什么很多人把 route 直接丢给 navigateTo 会失败。拼的时候记得补上/。另外如果你用的是分包,route 会带上分包根目录前缀,比如subpackage/user/profile,这是正常的,不是 bug,别去裁它。
3.2 完整版:把参数拼回带 query 的 url
只有 route 还不够,实际场景里我们往往需要完整的带参 url。这就涉及参数对象的序列化,写的时候有几个坑要绕开:值可能是 undefined、可能是数组、可能是已经编码过的字符串。
/** * 把参数对象序列化成 query string * 自动跳过 undefined / null,自动做 encodeURIComponent */ function stringifyQuery(options) { if (!options || typeof options !== 'object') return ''; const keys = Object.keys(options); if (keys.length === 0) return ''; return keys .map((key) => { const value = options[key]; if (value === undefined || value === null) return ''; return `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`; }) .filter(Boolean) .join('&'); } /** * 获取当前页面完整的 url * @param {boolean} withQuery 是否带上参数,默认 true */ function getCurrentUrl(withQuery = true) { const page = getCurrentPage(); if (!page) return ''; const route = page.route || ''; if (!withQuery) return route; // 老版本基础库在部分场景下 page.options 可能为 undefined const query = stringifyQuery(page.options || {}); return query ? `${route}?${query}` : route; } module.exports = { getCurrentPage, getCurrentRoute, getCurrentUrl, stringifyQuery, };这里解释一下几个设计取舍。为什么自己写 encodeURIComponent 而不是直接拼字符串?因为参数里但凡出现&、=、中文或者 emoji,不编码就一定会把 query 结构拆坏,接收端解析出来的参数数量会莫名变多。为什么过滤 undefined 而不是保留空字符串?因为?id=和?id在接收端的解析结果不一样,前者是空字符串,后者在某些解析逻辑里会变成 true 或者 undefined,容易引发判断错乱。统一丢掉更省心。
3.3 编码与特殊字符的处理细节
参数编码这件事,我见过太多翻车案例。最典型的是分享链接里带中文标题,没编码直接拼进去,用户点开之后标题变成一堆百分号乱码。还有一种更隐蔽:参数值里带了#,在某些解析实现里会被当成锚点截断,后面的内容全丢。
处理规则其实不复杂,记住两条就够:往外传的时候一律 encodeURIComponent,往里解的时候由平台负责。onLoad(options)拿到的参数,平台已经帮你 decode 过了,所以你在页面里直接用就行,不要再手动 decode 一次,否则一个本身就含%的参数会被解坏。但有一个例外,下一节会专门讲,就是扫码进入时的scene参数,那个是需要手动decodeURIComponent的。
还有一种情况是参数值本身是被编码过的 url,比如你从 A 页跳到 B 页,想把一个带参数的路径传过去做回调。这时候你要编码两次:先把自己的 url 编码一次变成字符串,再把整个字符串作为参数值编码一次。接收端解一次拿到原始 url,再解一次拿到最终参数。这套双重编码看起来绕,但在实现“跳转后回跳”这类功能时是绕不开的,建议封装成工具函数固定下来,避免每次手写。
4. 传参、解析、还原的完整实操链路
工具函数解决的是“读”的问题,接下来聊聊“写”——参数怎么传出去、特殊入口怎么解、分享时怎么还原。这三件事串起来就是一个完整的参数生命周期,缺哪一环都会在真实场景里掉链子。
4.1 跳转传参:什么时候必须编码
wx.navigateTo、wx.redirectTo、wx.reLaunch这三个 API 都接受带 query 的 url 字符串。很多人图省事直接模板字符串拼:
// 不推荐的写法 wx.navigateTo({ url: `/pages/detail/index?title=${title}&id=${id}`, });如果title里恰好有个&,这个 url 就废了。正确姿势是用我们上面写的stringifyQuery:
const { stringifyQuery } = require('../../utils/page.js'); const query = stringifyQuery({ title, id, from: 'list' }); wx.navigateTo({ url: `/pages/detail/index?${query}`, });还有一个经常被忽略的点:url 总长度是有限制的。虽然官方没有给出明确的字符数上限,但实测下来 query 部分超过一定长度之后,页面打开会失败或者参数被截断。我的经验值是整个 url 控制在 1000 字符以内比较稳妥,如果要传大数据,改用全局变量或者本地存储中转,别硬塞进 url。
注意:永远不要把 token、手机号、身份证这类敏感信息放在 url 参数里。小程序页面路径可以被分享出去,也可以被日志系统完整记录下来,等于把敏感数据广播了一遍。需要鉴权的信息走本地存储或者后端接口换取,不要图省事塞进 query。
4.2 扫码进入:scene 参数的解析姿势
小程序码(圆形那种)和普通二维码不一样,它携带的参数不是直接拼在 query 里,而是通过一个叫scene的字段传递,并且整个 scene 是经过 URL 编码的。你在onLoad(options)里拿到的options.scene是一串%E5%95%86%E5%93%81%3D10086这样的内容,必须先解码再解析。
Page({ onLoad(options) { if (options.scene) { // 小程序码场景:先解码,再按 & 和 = 拆 const scene = decodeURIComponent(options.scene); // scene 可能是 "id=10086&from=poster" 也可能是一个纯数字 const params = {}; scene.split('&').forEach((pair) => { const [key, value] = pair.split('='); if (key) params[key] = value; }); console.log('扫码参数', params); } else { console.log('普通进入参数', options); } }, });这里有个很实际的坑:scene 的内容长度是有限制的,所以很多团队会把参数压缩成短 key,比如用a=1&b=2这种,然后在页面里再映射回真实含义。另外 scene 里不能带中文,生成小程序码的时候就该把中文编码好,否则解码出来是乱码。
顺带说一句启动参数的事。用户从扫码进来,如果小程序是冷启动,这些参数会出现在wx.getEnterOptionsSync()的query里;如果是热启动,则应该从App.onShow(options)里接。两处都要处理,只处理一边的话,用户第二次扫码进来就会拿不到参数,这个 bug 我调过一回,排查起来挺折磨人的。
4.3 分享转发:用当前 url 反推 path
做分享功能时,通常希望“分享出去的链接就是用户当前看的这个页面”。这时候前面写的getCurrentUrl就派上用场了。
const { getCurrentUrl } = require('../../utils/page.js'); Page({ onShareAppMessage() { const url = getCurrentUrl(); return { title: '这个内容还不错,你看看', // path 必须以 / 开头,而 route 不带斜杠,所以这里要补 path: url ? `/${url}` : '/pages/index/index', }; }, });注意path必须带前导斜杠,这是硬性要求,漏了的话分享卡片点开会跳到默认首页,等于白分享。同时建议加个兜底:如果getCurrentUrl()返回空字符串,就给一个默认首页路径,避免分享出一个空 path 导致打开异常。
还有一个细节值得注意:分享出去的参数不要带动态的临时值,比如时间戳、随机数、当前会话 ID。这些值在接收端毫无意义,只会让链接看起来很奇怪,甚至导致接收端解析失败。分享参数应该是幂等的,同一个页面分享出去,谁打开看到的都一样,这才符合预期。
5. 真实项目里踩过的坑与排查思路
前面讲的都是“正常情况下怎么写”,这一章讲的是“不正常的时候为什么会崩”。这几个问题我在项目上都真实遇到过,有的还上了线上,排查过程记下来对后来人应该有用。
5.1 App.onLaunch 里页面栈是空的
这个是新手最容易撞的墙。想在小程序启动时上报一次入口信息,很自然地就在App.onLaunch里写getCurrentPages(),结果拿到空数组,代码直接报错。
原因:App.onLaunch的执行时机早于第一个页面实例的创建,此时页面栈还没建立。
解决:
App({ onLaunch(options) { // 这里用 getEnterOptionsSync 或直接用 onLaunch 的 options 参数 console.log('启动路径', options.path); console.log('启动参数', options.query); console.log('场景值', options.scene); }, });App.onLaunch的第一个参数本身就是启动配置,path和query都在里面,根本不需要去读页面栈。如果一定要在页面上下文中上报,把逻辑挪到首页的onShow里,那时候页面栈已经就绪了。
5.2 onShow 拿参数拿到的是上一次的值
场景是这样的:A 页跳到 B 页并传了参数,B 页操作完navigateBack回到 A 页,A 页的onShow里想拿最新的参数做刷新,结果拿到的还是它当初打开时的老参数。
原因:A 页从始至终没有被销毁重建,options不会更新。
解决:返回传参用两种方式。一种是全局状态,在getApp().globalData或者一个独立的状态模块里存一个待处理的值,A 页onShow里读一次然后清空。另一种是本地存储,wx.setStorageSync写入,A 页onShow读取后removeStorageSync清理。
// B 页返回前 const app = getApp(); app.globalData.backParams = { refreshed: true, from: 'detail' }; wx.navigateBack(); // A 页 onShow onShow() { const app = getApp(); const params = app.globalData.backParams; if (params) { this.handleRefresh(params); app.globalData.backParams = null; // 用完必须清空 } }注意:这个“用完清空”的动作非常关键。不清的话,用户下次正常返回 A 页时会莫名其妙又触发一次刷新,而且因为状态残留,排查起来特别费劲。
5.3 tabBar 页面参数“凭空消失”
想给 tabBar 页面传参,写下这样的代码:
wx.switchTab({ url: '/pages/home/index?id=10086', // 这个 id 传不进去 });结果onLoad里options是空的。这不是 bug,是设计如此:wx.switchTab的 url 不允许带参数,多传的部分会被直接忽略掉。
解决:tabBar 页面的参数只能走中转。要么存全局变量,要么写本地存储,然后在目标页的onShow(不是 onLoad,因为 tabBar 页面首次可能已经加载过)里读取。
// 跳转方 wx.setStorageSync('tabParams', { id: '10086' }); wx.switchTab({ url: '/pages/home/index' }); // 目标页 onShow() { const params = wx.getStorageSync('tabParams'); if (params && params.id) { wx.removeStorageSync('tabParams'); this.loadById(params.id); } }5.4 页面栈 10 层上限与参数断裂
页面栈上限 10 层这个限制,在内容型小程序里特别容易触发。用户从列表进详情、详情进评论、评论进用户主页……一层层点下去,到第 11 次navigateTo就失败了。失败的表现是跳转无反应或者 fail 回调被触发,但很多项目没写 fail 处理,用户看到的就是“点了没反应”。
排查思路:在跳转封装里统一加上 fail 回调,打日志并且尝试降级到redirectTo。
function safeNavigate(url) { wx.navigateTo({ url, fail(err) { console.warn('navigateTo 失败,降级 redirectTo', err); wx.redirectTo({ url }); }, }); }降级的代价是当前页面被替换掉,用户返回时会少一层,但总比点了没反应强。更彻底的方案是重新设计导航结构,把深层页面改成同一页内的状态切换,从根上避免栈深问题。
5.5 跳转前校验 url 与参数合法性
这个坑不算常见,但一旦出现就是安全问题。有些小程序的跳转 url 是从后端下发或者从参数里拼出来的,如果直接丢给navigateTo,理论上存在跳到非预期页面的风险。
解决:维护一份页面白名单,跳转前做一次校验。
const PAGE_WHITE_LIST = [ 'pages/index/index', 'pages/detail/index', 'pages/user/profile', ]; function isRouteAllowed(url) { if (typeof url !== 'string' || !url) return false; // 去掉前导斜杠和 query 部分,只留路由 const route = url.replace(/^\//, '').split('?')[0]; return PAGE_WHITE_LIST.includes(route); } function safeNavigate(url) { if (!isRouteAllowed(url)) { console.warn('拦截非法跳转', url); return; } wx.navigateTo({ url }); }同理,参数拿到之后也要做类型和范围校验。id应该是数字就转成数字再判断,type应该在枚举范围内就做一次 includes 检查。别觉得这是过度设计,等你因为一个脏参数导致页面白屏的时候,就会庆幸当初多写了这几行。
另外顺手提一句,页面不存在时的兜底也可以统一处理。小程序提供了onPageNotFound这个全局回调,用户真的跳到了不存在的页面时,会走到这里,你可以在里面重定向到首页。
App({ onPageNotFound(res) { console.warn('页面不存在', res.path); wx.reLaunch({ url: '/pages/index/index' }); }, });6. 高频问题速查与实操清单
讲到这儿,核心内容基本齐了。最后把最常见的几个问题和对应的处理方式整理成一张速查表,遇到问题的时候可以直接对号入座,省去翻文档的时间。
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
getCurrentPages()返回空数组 | 在App.onLaunch或页面创建前调用 | 改用wx.getEnterOptionsSync(),或延后到页面onShow |
onLoad里options为空 | 用了switchTab跳转,或参数没拼进 url | tabBar 传参走存储或全局变量;检查 url 拼接 |
| 参数里出现乱码百分号 | 传参时没做encodeURIComponent | 统一用stringifyQuery序列化 |
| 扫码进入拿不到参数 | 只处理了options.scene没处理options.query | 两个分支都写,冷热启动都覆盖 |
| 返回后参数没更新 | 页面未重建,options不刷新 | 用全局变量或存储做返回传参,onShow里读取并清空 |
| 分享卡片打开跳到首页 | path漏了前导斜杠,或返回空字符串 | 拼 path 时补/,并设置默认兜底路径 |
| 深层跳转点了没反应 | 页面栈超过 10 层 | 加fail回调降级到redirectTo,或重构导航 |
| 参数值莫名变成 true | query 写成了?id而不是?id= | 序列化时跳过undefined,不要留裸 key |
清单这部分我给几条我自己一定会做的操作习惯,算是给前面的内容收个口。第一,utils/page.js这类工具函数从项目第一天就建起来,别等页面写到十个再回头重构,成本完全不一样。第二,所有的页面跳转统一走一个封装函数,不直接在业务代码里裸调navigateTo,这样加日志、加校验、加降级都只改一个地方。第三,onLoad里拿到的参数立刻做一次类型转换和默认值填充,把容错前移,后面用的时候就不用层层判空了。
我在几个项目里跑下来最直观的感受是,取 url 和参数这件事本身代码量很小,难的是想清楚它在你这个业务里承担什么角色:是纯粹的埋点信息,还是页面渲染的驱动数据,还是跳转链路的状态中枢。定位不一样,封装方式和容错策略就完全不一样。埋点类的挂了顶多丢一条数据,驱动渲染的挂了就是白屏,这两者的校验强度绝对不能一样。我现在的习惯是,凡是参与渲染的参数,拿到之后一定先过一遍校验函数,不合法就立刻给默认值或者直接重定向回列表页,宁可多写十行,也不让用户看见一个空白页面。