ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

微信小程序页面传值四种方式详解:路由传参、页面栈、globalData与本地缓存选型指南

微信小程序页面传值四种方式详解:路由传参、页面栈、globalData与本地缓存选型指南

微信小程序开发里,页面之间的数据传递是绕不开的基础功。很多刚上手的朋友写着写着就发现,A页面选好的东西,跳到B页面就取不到了;或者从详情页返回列表页,列表还是老样子,没刷新。这类问题十有八九都出在传值方式没选对。我自己做小程序这几年,路由传参、页面栈、globalData、本地缓存这四种方式来回用,踩过的坑也不少。今天就把这四种方式从头到尾捋一遍,讲清楚每种方式适合什么场景、具体怎么写、有哪些容易翻车的地方。不管你是刚接触小程序的新手,还是写过几个项目但传值总是靠感觉来的朋友,这篇内容应该都能帮你把这块知识补扎实。

1. 页面传值的整体设计与选型思路

1.1 为什么传值方式不能一招通吃

小程序和传统的Web页面不太一样。它没有浏览器地址栏那种可以随便拼参数的URL,页面跳转靠的是wx.navigateTo、wx.redirectTo、wx.switchTab这一套API,页面之间的数据通道相对封闭。同时小程序又存在一个"页面栈"的概念,最多同时存在10层页面,超过之后navigateTo就跳不动了。这些机制决定了传值不能只靠一种手段。

我见过不少项目,所有传值都往globalData里塞,结果页面一多,全局变量满天飞,改一个地方不知道会影响谁。也见过全部用缓存传的,读的时候忘了同步,拿到的是上一次的旧数据。所以选型这件事,核心是看三个维度:数据要传多远(相邻页面还是跨多级)、数据要活多久(一次性还是长期保留)、数据要不要响应式更新(变了之后相关页面要不要自动感知)。

把这三点想清楚,四种方式的分工其实很清晰。路由传参适合相邻页面之间传简单、一次性的数据;页面栈适合从深层页面反向操作上级页面;globalData适合全局共享、生命周期跟随App的数据;缓存适合需要持久化、跨会话保留的数据。下面这张表是我自己总结的对照,先给个整体印象。

传值方式典型场景数据生命周期是否响应式容量限制
路由传参列表跳详情带id随页面实例否URL长度有限
页面栈详情页回写列表随页面实例否无
globalData登录态、用户信息随App否(需手动同步)内存限制
本地缓存草稿、历史记录持久化否单key 1MB,总10MB

1.2 四种方式的本质区别

路由传参的本质是把数据拼在跳转的URL上,目标页面在onLoad的options参数里拿到。它走的是页面跳转这条链路,所以数据只在"跳过去"这个动作发生时传递一次,页面一旦加载完,再想改就得重新跳。

页面栈的本质是拿到目标页面的实例对象,直接调用它的方法或改它的data。getCurrentPages()返回的是一个数组,数组里每个元素就是一个页面实例,你可以像操作普通对象一样操作它。这种方式是"反向"的,通常用在子页面操作父页面。

globalData是挂在App实例上的一个普通对象,任何页面通过getApp()都能拿到同一个引用。它不参与页面跳转,纯粹是内存里的一块共享区域。

本地缓存走的是wx.setStorageSync这一套,数据存在设备上,关掉小程序再打开还在。它和内存无关,是真正落盘的东西。

理解这四个本质,后面具体怎么用就顺了。

2. 路由传参的细节与实操要点

2.1 基础写法与参数拼接

路由传参最常见的形式就是在跳转URL后面拼查询字符串。比如从列表页跳到详情页,带上商品id:

// 列表页 wx.navigateTo({ url: '/pages/detail/detail?id=123&from=list' }) // 详情页 onLoad onLoad(options) { console.log(options.id) // '123' console.log(options.from) // 'list' }

这里有个细节很多人第一次会懵:options里拿到的值全是字符串。哪怕你传的是id=123,拿到的也是'123'而不是123。如果后面要拿它做数值比较或者计算,记得手动转一下,Number(options.id)或者parseInt(options.id, 10)。

传多个参数就用&连接。但这里有个坑,如果参数值本身包含特殊字符,比如中文、空格、&、=这些,直接拼进去会出问题。中文在某些环境下会乱码,&会被当成参数分隔符。正确做法是用encodeURIComponent编码:

const keyword = '手机 & 配件' wx.navigateTo({ url: `/pages/search/search?kw=${encodeURIComponent(keyword)}` }) // 接收页 onLoad(options) { const kw = decodeURIComponent(options.kw) // '手机 & 配件' }

注意:encodeURIComponent和decodeURIComponent要成对使用。只编码不解码,拿到的是%E6%89%8B%E6%9C%BA这种;只解码不编码,遇到特殊字符照样出问题。

2.2 传递对象的正确姿势

路由传参只能传字符串,那要传一个对象怎么办?最直接的办法是JSON.stringify之后编码再传:

const item = { id: 1, name: '测试商品', price: 99 } wx.navigateTo({ url: `/pages/detail/detail?data=${encodeURIComponent(JSON.stringify(item))}` }) // 接收页 onLoad(options) { const item = JSON.parse(decodeURIComponent(options.data)) }

这个写法能用,但我不太推荐在参数复杂的时候用。原因有两个:一是URL长度有限制,对象一大就可能被截断;二是编码后的字符串又长又难读,调试的时候看着头疼。如果数据确实复杂,我更倾向于只传一个id,目标页面拿着id自己去请求完整数据。这样URL干净,数据也总是最新的。

2.3 路由传参的适用边界

路由传参最大的优点是简单直接、无状态。数据跟着跳转走,不依赖任何全局变量,页面刷新(重新进入)也能拿到。但它的边界也很明显:

  • 只能单向传,从A到B,B没法通过这个通道回传给A
  • 数据是快照,传过去之后源数据变了,目标页面不会跟着变
  • 不适合传大对象,URL长度和编码成本都是问题
  • switchTab跳转的页面不支持带参数,这是tabBar页面的限制,很多人在这里翻车

最后这条特别容易踩。tabBar页面用wx.switchTab跳转,URL后面拼的参数会被忽略。如果确实要给tabBar页面传值,得换globalData或者缓存。

3. 页面栈的玩法与反向传值

3.1 getCurrentPages 的基本用法

页面栈是小程序里一个很有特色的机制。getCurrentPages()返回当前页面栈的数组,数组最后一项是当前页面,倒数第二项就是上一个页面。通过这个数组,你可以直接拿到上级页面的实例,进而操作它的数据或方法。

// 在详情页里操作列表页 const pages = getCurrentPages() const prevPage = pages[pages.length - 2] prevPage.setData({ selectedId: 123 })

这段代码的意思是:拿到上一个页面(列表页)的实例,把它的selectedId改成123。列表页的视图会立刻响应更新。这就是所谓的"反向传值",从子页面回写父页面。

3.2 反向传值的典型场景

最典型的场景是"选择器"模式。比如列表页有个筛选条件,点击后跳到筛选页,用户在筛选页选完条件,点确定,要回到列表页并让列表按新条件刷新。这时候路由传参帮不上忙,因为数据是从筛选页往列表页传的,方向反了。

用页面栈就能优雅解决:

// 筛选页,用户点确定 onConfirm() { const pages = getCurrentPages() const prevPage = pages[pages.length - 2] prevPage.setData({ filterType: this.data.selectedType, filterKeyword: this.data.keyword }) wx.navigateBack() }

列表页的data被更新后,如果它监听了这些字段做请求,就会自动刷新。这种模式比用globalData中转要干净,因为数据只在这两个页面之间流动,不会污染全局。

3.3 页面栈操作的注意事项

页面栈虽然好用,但有几个坑必须提醒:

第一,不要跨太多级操作。pages[pages.length - 3]这种写法理论上可行,但可读性极差,而且一旦页面跳转层级变了,代码就崩了。跨级传值还是用globalData或缓存更稳妥。

第二,页面实例可能不存在。如果用户是通过分享链接直接进的详情页,那页面栈里可能只有一层,pages[pages.length - 2]就是undefined,直接调setData会报错。所以操作前一定要判空:

const pages = getCurrentPages() if (pages.length >= 2) { const prevPage = pages[pages.length - 2] prevPage.setData({ ... }) }

第三,直接调上级页面的方法要谨慎。有些人喜欢prevPage.refreshList()这样直接调方法,但这个方法名一旦改了,调用方就断了。相比之下,改data然后让上级页面自己监听更解耦。

实操心得:我一般会在上级页面里约定一个updateFromChild方法,专门处理子页面回传的数据。子页面只调这一个方法,内部逻辑怎么变都不影响调用方。

4. globalData 全局数据管理

4.1 globalData 的定义与读取

globalData定义在app.js里,是App实例的一个属性:

// app.js App({ globalData: { userInfo: null, token: '', systemInfo: null } })

任何页面里通过getApp()拿到App实例,再访问globalData:

// 某个页面 const app = getApp() console.log(app.globalData.userInfo) // 修改 app.globalData.token = 'new_token'

它本质上就是一个挂在全局对象上的普通JS对象,读写没有任何魔法,就是内存操作。

4.2 globalData 的典型应用

globalData最适合放全局共享、生命周期跟随整个App的数据。最典型的就是登录态和用户信息。用户登录一次,所有页面都要知道当前用户是谁,这种数据放globalData最合适。

// 登录成功后 const app = getApp() app.globalData.userInfo = res.data.userInfo app.globalData.token = res.data.token // 其他页面直接用 const app = getApp() if (app.globalData.token) { // 已登录 }

还有一个常见用法是存系统信息,比如状态栏高度、屏幕宽度这些,在onLaunch里算一次,全局复用,避免每个页面都调一遍wx.getSystemInfoSync。

4.3 globalData 的响应式问题

globalData最大的问题是它不响应式。你改了app.globalData.userInfo,已经渲染过的页面不会自动更新。这跟Vuex、Redux那种状态管理库不一样,globalData就是个裸对象。

那怎么让页面感知变化?常见做法有两种。一种是页面在onShow里主动读一次:

onShow() { const app = getApp() this.setData({ userInfo: app.globalData.userInfo }) }

另一种是配合回调或者事件机制。比如在globalData里存一个回调函数,数据变了就调一下:

// app.js globalData: { userInfo: null, userInfoCallback: null } // 设置数据的地方 app.globalData.userInfo = newInfo if (app.globalData.userInfoCallback) { app.globalData.userInfoCallback(newInfo) } // 页面里注册回调 onLoad() { const app = getApp() app.globalData.userInfoCallback = (info) => { this.setData({ userInfo: info }) } }

这种方式能用,但回调多了之后管理起来很乱,容易忘记注销导致内存泄漏。如果项目里全局状态确实复杂,我建议直接上专门的方案,而不是硬堆globalData。

4.4 globalData 的使用禁忌

globalData用起来方便,但滥用会带来维护灾难。我的经验是守住两条线:

一是只放真正全局的数据。页面A和页面B之间的临时传值,不要往globalData里塞。判断标准很简单:这个数据是不是三个以上不相关的页面都要用?不是的话就别放全局。

二是命名要清晰、结构要扁平。globalData里塞太多嵌套对象,改起来容易出错。我一般会按模块分一下,比如user、config、temp,但层级不超过两层。

5. 本地缓存的持久化传值

5.1 同步与异步 API 的选择

小程序的缓存API分同步和异步两套。同步的是wx.setStorageSync、wx.getStorageSync,异步的是wx.setStorage、wx.getStorage。同步的写起来简单,直接拿返回值;异步的要用回调或者Promise。

// 同步 wx.setStorageSync('draft', { title: '草稿', content: '...' }) const draft = wx.getStorageSync('draft') // 异步 wx.setStorage({ key: 'draft', data: { title: '草稿' }, success() {} })

选哪个?我的原则是:小数据、不频繁的操作用同步,大数据或频繁操作用异步。同步API会阻塞当前线程,如果存的数据大或者调用频繁,会卡界面。异步虽然写起来麻烦点,但不阻塞。

5.2 缓存的容量与清理

小程序单个key的缓存上限是1MB,所有缓存加起来上限是10MB。超过之后setStorage会失败。所以缓存不能当数据库用,它只适合存轻量的、需要持久化的数据。

缓存不会自动清理,除非用户主动删除小程序或者系统清理。所以自己写的数据要自己负责清理。常见做法是给缓存加时间戳,读取时判断是否过期:

// 存 wx.setStorageSync('cache_data', { data: res.data, expire: Date.now() + 3600 * 1000 // 1小时后过期 }) // 读 const cache = wx.getStorageSync('cache_data') if (cache && cache.expire > Date.now()) { // 用缓存 } else { // 重新请求 wx.removeStorageSync('cache_data') }

5.3 缓存传值的典型场景

缓存传值最典型的场景是跨会话保留。比如用户填了一半的表单,退出了小程序,下次进来还想接着填,这就得靠缓存。路由传参和globalData都做不到,因为它们随页面或App实例销毁而消失。

另一个场景是tabBar页面之间的传值。前面说过switchTab不支持带参数,那A tab要传值给B tab怎么办?可以A tab把数据写进缓存,B tab在onShow里读缓存。虽然有点绕,但确实能解决问题。

// A tab 页面 wx.setStorageSync('tab_message', { from: 'A', content: '...' }) wx.switchTab({ url: '/pages/tabB/tabB' }) // B tab 页面 onShow() { const msg = wx.getStorageSync('tab_message') if (msg) { this.setData({ message: msg }) wx.removeStorageSync('tab_message') // 用完就删 } }

注意:用完记得删。缓存不清,下次进B tab还会读到旧数据,容易出诡异bug。

6. 四种方式的组合与选型实战

6.1 一个完整的传值场景拆解

光讲理论不够,我们拿一个完整的场景走一遍。假设有个电商小程序,流程是:首页 → 商品列表 → 商品详情 → 下单页 → 订单结果页。每一步都有传值需求。

首页到商品列表,可能带个分类id,用路由传参就够了。列表到详情,带商品id,也是路由传参。详情到下单页,要带商品信息、数量、规格,数据比较复杂,这时候可以只传商品id和数量,下单页自己请求详情,或者用globalData暂存一下选中的规格。

下单页到订单结果页,通常用redirectTo跳转(因为不希望用户返回下单页),带订单号,路由传参。结果页可能还要展示用户信息,从globalData拿。

整个流程里,路由传参承担了大部分工作,globalData负责全局的用户信息,缓存可能用来存购物车草稿。页面栈则用在"从详情页返回列表页时刷新列表"这种反向场景。

6.2 选型决策表

我把选型逻辑整理成一张决策表,遇到具体问题时对着看:

问题推荐方式
相邻页面传简单参数路由传参
子页面回写父页面页面栈
全局登录态、用户信息globalData
tabBar页面之间传值缓存 + onShow读取
数据需要跨会话保留缓存
传复杂对象且不想请求globalData 或 缓存
数据需要实时响应专门的响应式方案,非以上四种

6.3 组合使用的注意事项

组合使用的时候,最容易出的问题是数据源不统一。比如同一个用户信息,既存在globalData又存在缓存里,两边不同步,页面读的时候不知道以哪个为准。我的建议是:同一份数据只用一个权威来源,其他地方要么是它的副本,要么通过它派生。

还有一点是清理时机。缓存和globalData都需要在合适的时机清理。比如用户退出登录,globalData里的token要清,缓存里的用户相关数据也要清。漏掉任何一个,都可能造成数据串号。

7. 常见问题与排查技巧实录

7.1 传值丢失的排查思路

传值丢失是最常见的问题,排查时按这个顺序走:

第一步,确认跳转方式。navigateTo、redirectTo、switchTab、reLaunch对参数的支持不一样。switchTab和reLaunch都不支持带参数,这是最容易被忽略的。

第二步,确认参数编码。中文、特殊字符没编码,接收端拿到的可能是乱码或者被截断的值。

第三步,确认接收时机。onLoad只执行一次,如果数据是在页面已经加载完之后才变的,onLoad里读不到最新值,得在onShow里读。

第四步,确认页面栈层级。用getCurrentPages反向传值时,层级算错了就会拿到错误的页面实例。

7.2 缓存读不到数据的几种原因

缓存读不到,通常是这几种情况:

  • key写错了,大小写、拼写不一致
  • 存的时候用了异步API,读的时候数据还没写完
  • 缓存被系统清理了(存储空间不足时会发生)
  • 存的是对象,读出来发现是字符串(某些情况下序列化行为不一致)

排查的时候,我习惯先wx.getStorageInfoSync()看一下当前所有key,确认数据到底在不在。

7.3 常见问题速查表

现象可能原因解决方式
接收页options为空用了switchTab跳转改用缓存或globalData
中文参数乱码未编码encodeURIComponent
反向传值报错页面栈层级不足判空后再操作
globalData改了页面不更新非响应式onShow里重新读取
缓存读到旧数据未清理用完removeStorageSync
传对象后解析失败未stringifyJSON.stringify + encode

7.4 几个我踩过的坑

说几个印象深刻的。有一次做筛选功能,筛选页选完条件用navigateBack返回,结果列表页没刷新。查了半天发现是列表页只在onLoad里读了筛选条件,而navigateBack不会触发onLoad,只触发onShow。改成在onShow里读就好了。

还有一次,用globalData存用户信息,登录后跳转到首页,首页显示的还是未登录状态。原因是首页在onLoad里读的globalData,而登录动作发生在首页加载之后。后来改成在onShow里读,问题解决。

缓存这块踩过一个更隐蔽的。存了一个对象,读出来发现多了些字段,后来发现是不同版本的小程序对同一个key写入的数据结构不一样,旧版本的数据没清,新版本读到了旧结构。解决办法是给缓存加版本号,版本不匹配就丢弃重存。

7.5 性能与体验上的小技巧

传值虽然是小功能,但用不好也影响体验。几个小建议:

  • 路由传参尽量只传id,不传大对象,减少URL长度和解析成本
  • globalData的读取放在onShow而不是onLoad,保证拿到最新值
  • 缓存写入用异步,避免阻塞渲染
  • 反向传值优先改data而不是直接调方法,降低耦合
  • 所有跨页面传值都加判空,防止页面栈异常导致崩溃

这些细节单看都不起眼,但项目一大,就是它们决定了代码是清爽还是混乱。传值这件事,说到底是想清楚数据的流向和生命周期,选对工具,剩下的就是细心。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进