4、组件通信(下):跨页面组件通信(EventBus)、全局状态管理(Store)与组件的关系

上一节我们聊了父子组件怎么传值、怎么回调。但说实话,那些套路只解决了一个页面内的问题。

真正让人头疼的,是跨页面通信。

比如你在商品列表页加了个购物车,跳转到详情页再返回,购物车数量怎么更新?又或者用户登录后,所有页面的头像都得跟着变。这种场景,父子组件那套玩法就彻底没用了。

我早期做小程序时,就踩过这个坑。当时用了一个特别笨的办法——每个页面 onShow 时重新请求数据。结果页面多了,接口请求满天飞,用户体验极差。后来才意识到,得用 EventBus 或者全局状态管理。

4.1 跨页面通信的痛点

先说说为什么跨页面通信这么麻烦。

小程序里,每个页面都是独立的 WebView。页面 A 和页面 B 之间,没有直接的引用关系。你不能像父子组件那样直接传 props,也不能用 ref 去调用对方的方法。

那怎么办?

常见的思路就两条:

  • 事件总线(EventBus):一个全局的发布订阅模式。谁想发消息,就往总线里丢;谁想收消息,就提前订阅。
  • 全局状态管理(Store):一个统一的数据仓库。所有页面都从仓库里读数据,谁改了数据,仓库通知所有订阅者。

这两者有什么区别?我个人的理解是:EventBus 适合「通知」场景,比如「你该刷新了」;Store 适合「数据共享」场景,比如「当前用户信息长这样」。

4.2 EventBus:轻量级的跨页面通信

EventBus 说白了就是一个全局的事件管理器。它不依赖任何页面,独立存在于 App 实例或者全局对象上。

我习惯把它放在 app.js 里,或者单独抽一个文件。

核心思路: 一个对象,三个方法——on(订阅)、emit(触发)、off(取消订阅)。

来看一个最简单的实现:

// eventBus.js
class EventBus {
  constructor() {
    this.events = {}
  }

  on(event, callback) {
    if (!this.events[event]) {
      this.events[event] = []
    }
    this.events[event].push(callback)
  }

  emit(event, data) {
    const cbs = this.events[event]
    if (cbs) {
      cbs.forEach(cb => cb(data))
    }
  }

  off(event, callback) {
    const cbs = this.events[event]
    if (cbs) {
      this.events[event] = cbs.filter(cb => cb !== callback)
    }
  }
}

export default new EventBus()

然后在 app.js 里挂载到全局:

// app.js
import EventBus from './utils/eventBus'

App({
  onLaunch() {
    this.eventBus = EventBus
  }
})

使用的时候,页面 A 触发事件:

// pageA.js
const app = getApp()

Page({
  addToCart() {
    // 加入购物车逻辑...
    app.eventBus.emit('cartUpdated', { count: 5 })
  }
})

页面 B 订阅事件:

// pageB.js
const app = getApp()

Page({
  onLoad() {
    app.eventBus.on('cartUpdated', this.handleCartUpdate)
  },

  handleCartUpdate(data) {
    this.setData({ cartCount: data.count })
  },

  onUnload() {
    // 记得取消订阅,防止内存泄漏
    app.eventBus.off('cartUpdated', this.handleCartUpdate)
  }
})

注意: 我曾经在项目里忘了在 onUnload 里取消订阅,结果页面来回跳转几次,事件回调被重复注册,每次触发都执行好几遍。嗯,这个坑踩过一次就再也不会忘了。

4.3 全局状态管理(Store):更规范的数据共享

EventBus 虽然轻量,但用多了容易乱。事件满天飞,你都不知道谁发了谁收了。这时候,Store 就派上用场了。

小程序官方没有提供类似 Vuex 或 Redux 的库,但我们可以自己实现一个简单的 Store。核心思想就三点:

  • 单一数据源:所有共享数据放在一个对象里
  • 数据驱动视图:数据变了,自动通知订阅的页面更新
  • 统一修改入口:不能直接改数据,必须通过方法

我常用的一个轻量 Store 实现:

// store.js
class Store {
  constructor(options) {
    this.state = options.state || {}
    this.mutations = options.mutations || {}
    this._subscribers = []
  }

  // 订阅状态变化
  subscribe(callback) {
    this._subscribers.push(callback)
  }

  // 取消订阅
  unsubscribe(callback) {
    this._subscribers = this._subscribers.filter(cb => cb !== callback)
  }

  // 提交 mutation,修改状态
  commit(mutation, payload) {
    const fn = this.mutations[mutation]
    if (fn) {
      fn(this.state, payload)
      // 通知所有订阅者
      this._subscribers.forEach(cb => cb(this.state))
    } else {
      console.warn(`Unknown mutation: ${mutation}`)
    }
  }

  // 获取状态
  getState() {
    return this.state
  }
}

// 创建 store 实例
const store = new Store({
  state: {
    userInfo: null,
    cartCount: 0,
    theme: 'light'
  },
  mutations: {
    setUserInfo(state, payload) {
      state.userInfo = payload
    },
    updateCartCount(state, payload) {
      state.cartCount = payload
    },
    setTheme(state, payload) {
      state.theme = payload
    }
  }
})

export default store

app.js 里挂载:

// app.js
import store from './utils/store'

App({
  onLaunch() {
    this.store = store
  }
})

页面里使用:

// pageA.js
const app = getApp()

Page({
  onLoad() {
    // 订阅状态变化
    app.store.subscribe(this.handleStateChange)
    // 初始化数据
    this.setData({ cartCount: app.store.getState().cartCount })
  },

  handleStateChange(state) {
    this.setData({ cartCount: state.cartCount })
  },

  addToCart() {
    // 通过 commit 修改状态
    app.store.commit('updateCartCount', this.data.cartCount + 1)
  },

  onUnload() {
    app.store.unsubscribe(this.handleStateChange)
  }
})

小技巧: 我习惯在 Store 里加一个 watch 方法,只监听某个字段的变化,而不是整个 state。这样性能更好,代码也更清晰。你想想看,如果页面只关心 cartCount,却要因为 userInfo 的变化也触发更新,那多浪费。

4.4 EventBus vs Store:怎么选?

很多新手会纠结这个问题。我直接说结论:

场景 推荐方案 原因
通知某个页面「去刷新」 EventBus 轻量,不需要维护状态,发完即止
多个页面共享同一份数据 Store 数据一致性有保障,修改入口统一
页面间传递复杂参数 Store EventBus 传参容易丢失上下文
简单的一次性通知 EventBus 写 Store 有点杀鸡用牛刀

说白了,EventBus 是「喊一嗓子」,Store 是「写黑板报」。两者不冲突,甚至可以混用。我自己的项目里,通常 Store 管核心数据(用户、购物车、配置),EventBus 管临时通知(刷新列表、弹窗提示)。

4.5 组件与 Store 的关系:从「被动接收」到「主动订阅」

有了 Store 之后,组件和数据的交互方式就变了。

以前是父组件传什么,子组件用什么。现在子组件可以直接从 Store 里读数据,谁都不用求。

我举个例子。一个购物车角标组件,放在全局的导航栏里:

// cart-badge.js
Component({
  properties: {},
  data: {
    count: 0
  },
  lifetimes: {
    attached() {
      // 组件挂载时订阅 Store
      const app = getApp()
      this._storeCallback = (state) => {
        this.setData({ count: state.cartCount })
      }
      app.store.subscribe(this._storeCallback)
      // 初始化数据
      this.setData({ count: app.store.getState().cartCount })
    },
    detached() {
      // 组件销毁时取消订阅
      const app = getApp()
      app.store.unsubscribe(this._storeCallback)
    }
  }
})

你看,这个组件完全不依赖父页面。不管它在哪个页面里,只要 Store 里的 cartCount 变了,它自动更新。这就是「主动订阅」的魅力。

核心变化: 组件从「被动接收数据」变成了「主动订阅数据」。数据流不再是树状的,而是网状的。任何页面、任何组件,只要订阅了同一个数据源,就能保持同步。

4.6 避坑指南

最后分享几个我踩过的坑:

  • 内存泄漏:EventBus 和 Store 的订阅,一定要在页面或组件销毁时取消。我曾经有个页面,用户来回切换了十几次,结果每次切换都新增一个订阅,最后页面卡得不行。
  • 数据覆盖:多个页面同时修改 Store 里的同一个字段,要注意顺序。我习惯在 mutation 里加一些防抖或队列处理。
  • 不要滥用全局状态:不是所有数据都适合放 Store。只有真正需要跨页面共享的数据才放进去。页面内部的临时状态,老老实实用 data 就好。
  • 命名规范:EventBus 的事件名和 Store 的 mutation 名,最好统一管理。我习惯在项目里建一个 constants.js,把所有事件名和 mutation 名定义成常量,避免拼写错误。

嗯,跨页面通信这块,说白了就是「解耦」两个字。EventBus 和 Store 都是工具,关键看你怎么用。我个人建议,小项目用 EventBus 就够了,项目大了再上 Store。别一开始就搞得太重,灵活一点。