EventChannel

# EventChannel

在本文中,我们将探讨如何在 uniapp 中利用 EventChannel 实现页面之间的高效通信。EventChannel 是 uniapp 提供的一种专门用于页面间通信的机制,特别适合在页面间传递数据或更新状态的场景。

# 实际业务场景

为了更好地理解 EventChannel 的应用,我们来看一个典型的业务场景:假设在一个电商应用中,有一个订单列表,其中某个订单的状态为“待付款”。用户点击该订单,进入订单详情页面。在订单详情页面,用户选择“取消订单”,操作成功后,该订单的状态变为“已取消”。

此时,我们面临一个问题:如何在订单详情页面取消订单后,自动更新订单列表页面中对应订单的状态,使其从“待付款”变为“已取消”。

一种简单直接的解决方案是在订单列表页面中使用 onShow 生命周期方法,在每次页面重新展示时调用接口重新获取订单列表。然而,这种方法存在以下缺点:

  • 资源浪费:用户每次切换页面都会触发加载操作,导致不必要的网络请求和资源浪费。
  • 用户体验不佳:如果用户在订单列表中点击的订单位于列表中间或底部,跳转回列表页面后,滚动位置可能会丢失,导致用户体验不佳。

为了解决这些问题,我们可以采用一种更加无缝和智能的方式,这正是 EventChannel 的用武之地。

EventChannel 可以在两个页面间建立通信通道,当订单详情页面的状态发生变化时,及时通知订单列表页面更新状态,无需额外的页面刷新或滚动位置丢失。此外,EventChannel 也不是唯一的选择。我们还可以通过全局状态管理(如 Paina)、EventBus 等方式来实现类似的效果。

在接下来的部分,我们将详细介绍如何使用 EventChannel 实现这一场景下的页面通信。

# 使用方法

# 在订单列表页面监听事件

// order.vue
const orderList = ref([]);
uni.navigateTo({
  url: '/order-detail',
  events: {
    orderStatusChange({ orderId, status }) {
      // 从列单列表中查询对应项
      const index = orderList.value.findIndex((order) => order.id === orderId);
      if (index !== -1) {
        // 修改订单状态
        orderList.value[index].status = data.status;
        // 同样删除也可以
        //  orderList.value.splice(index, 1);
      }
    },
  },
});

在跳转到订单详情页面时,通过 navigateTo 方法传递 events 属性,用于监听订单状态变化事件。

# 在订单详情页面发送事件

// order-detail.vue
const { proxy } = getCurrentInstance();
let eventChannel = null;
const handleCancel = () => {
  // 取消订单操作
  // ...
  // 执行通信
  eventChannel.emit('orderStatusChange', {
    orderId: '1234567',
    status: 'cancel',
  });
};
onLoad(() => {
  eventChannel = proxy.getOpenerEventChannel();
});

在订单详情页面中,通过 getOpenerEventChannel 方法获取到打开订单详情页面的 EventChannel 对象,并通过调用 emit 方法发送消息。

# EventChannel 是什么

事实上,·EventChannel 的内部实现可以看作是一个自定义事件类,通过事件的发布(emit)和订阅(on)机制,实现页面之间的数据通信。

下面就是具体实现。

type NavigateToOptionEvents = Record<string, (...args: any[]) => void>

interface EventChannelListener {
  type: 'on' | 'once'
  fn: (...args: any[]) => void
}

export class EventChannel {
  id?: number
  private listener: Record<string, EventChannelListener[]>
  private emitCache: {
    args: any[]
    eventName: string
  }[]
  constructor(id?: number, events?: NavigateToOptionEvents) {
    this.id = id
    this.listener = {}
    this.emitCache = []
    if (events) {
      Object.keys(events).forEach((name) => {
        this.on(name, events[name])
      })
    }
  }

  emit(eventName: string, ...args: any[]) {
    const fns = this.listener[eventName]
    if (!fns) {
      return this.emitCache.push({
        eventName,
        args,
      })
    }
    fns.forEach((opt) => {
      opt.fn.apply(opt.fn, args)
    })
    this.listener[eventName] = fns.filter((opt) => opt.type !== 'once')
  }

  on(eventName: string, fn: EventChannelListener['fn']) {
    this._addListener(eventName, 'on', fn)
    this._clearCache(eventName)
  }

  once(eventName: string, fn: EventChannelListener['fn']) {
    this._addListener(eventName, 'once', fn)
    this._clearCache(eventName)
  }

  off(eventName: string, fn: EventChannelListener['fn']) {
    const fns = this.listener[eventName]
    if (!fns) {
      return
    }
    if (fn) {
      for (let i = 0; i < fns.length; ) {
        if (fns[i].fn === fn) {
          fns.splice(i, 1)
          i--
        }
        i++
      }
    } else {
      delete this.listener[eventName]
    }
  }

  _clearCache(eventName?: string) {
    for (let index = 0; index < this.emitCache.length; index++) {
      const cache = this.emitCache[index]
      const _name = eventName
        ? cache.eventName === eventName
          ? eventName
          : null
        : cache.eventName
      if (!_name) continue
      const location = this.emit.apply(this, [_name, ...cache.args])
      if (typeof location === 'number') {
        this.emitCache.pop()
        continue
      }
      this.emitCache.splice(index, 1)
      index--
    }
  }

  _addListener(
    eventName: string,
    type: EventChannelListener['type'],
    fn: EventChannelListener['fn']
  ) {
    ;(this.listener[eventName] || (this.listener[eventName] = [])).push({
      fn,
      type,
    })
  }
}

在了解 EventChannel 实现之后,接下来看下两个页面是如何共享 EventChannel 实例的。

# 两个页面是如何共享 EventChannel 实例的

页面之间之所以能够实现通信,关键在于它们共享同一个 EventChannel 实例。同时,新页面能够通过调用 getOpenerEventChannel 方法获取到这个共享的实例。

要深入理解这一机制,我们需要了解 uni.navigateTo 方法和 getOpenerEventChannel 方法各自的工作原理。

接下来看下内部实现:

function navigateTo({
  url,
  path,
  query,
  events,
  aniType,
  aniDuration,
}: NavigateToOptions): Promise<void | { eventChannel: EventChannel }> {
  // 创建 eventChannel
  const eventChannel = new EventChannel(getWebviewId() + 1, events);
  return new Promise((resolve) => {
    showWebview(
      registerPage({ url, path, query, openType: 'navigateTo', eventChannel }),
      aniType,
      aniDuration,
      () => {
        resolve({ eventChannel });
      }
    );
    setStatusBarStyle();
  });
}

上面看到把 eventChannel 传递给了 registerPage 方法。

// registerPage 方法
export function registerPage({
  url,
  path,
  query,
  openType,
  webview,
  nvuePageVm,
  eventChannel,
}: RegisterPageOptions) {
  if (!webview) {
    webview = createWebview({ path, routeOptions, query })
  } else {
    webview = plus.webview.getWebviewById(webview.id)
    ;(webview as any).nvue = routeOptions.meta.isNVue
  }
  initWebview(webview, path, query, routeOptions.meta)

  const pageInstance = initPageInternalInstance(
    // ...,
    eventChannel,
  )
  createVuePage(id, route, query, pageInstance, initPageOptions(routeOptions))
  return webview
}

在 registerPage 中主要做了几件事:

  • 创建 webview 实例
  • 初始化 webview
  • 初始化页面内部实例
  • 创建 vue 页面
export function initPageInternalInstance(
  openType: UniApp.OpenType,
  url: string,
  pageQuery: Record<string, any>,
  meta: UniApp.PageRouteMeta,
  eventChannel?: EventChannel,
  themeMode?: UniApp.ThemeMode
): Page.PageInstance['$page'] {
  const { id, route } = meta
  return {
    id: id!,
    route: route,
    fullPath: url,
    options: pageQuery,
    meta,
    openType,
    eventChannel,
    statusBarStyle: titleColor === '#ffffff' ? 'light' : 'dark',
  }
}

这方法并没有做太多逻辑处理。 只要记住 eventChannel 放入内部实例中,接着往下看 createVuePage

function createFactory(component: VuePageAsyncComponent | VuePageComponent) {
  return () => {
    if (isVuePageAsyncComponent(component)) {
      return component().then((component) => setupPage(component))
    }
    return setupPage(component)
  }
}
export const pagesMap = new Map<string, ReturnType<typeof createFactory>>()

export function definePage(
  pagePath: string,
  asyncComponent: VuePageAsyncComponent | VuePageComponent
) {
  pagesMap.set(pagePath, once(createFactory(asyncComponent)))
}

export function createVuePage(
  __pageId: number,
  __pagePath: string,
  __pageQuery: Record<string, any>,
  __pageInstance: Page.PageInstance['$page'],
  pageOptions: PageNodeOptions
) {
  const pageNode = createPageNode(__pageId, pageOptions, true)
  // 获取 Vue 根应用
  const app = getVueApp()
  // 根据路径获取页面组件
  const component = pagesMap.get(__pagePath)!()
  // 页面挂载
  const mountPage = (component: VuePageComponent) =>
    app.mountPage(
      component,
      extend(
        {
          __pageId,
          __pagePath,
          __pageQuery,
          __pageInstance,
        },
        __pageQuery
      ),
      pageNode
    )
  if (isPromise(component)) {
    return component.then((component) => mountPage(component))
  }
  return mountPage(component)
}

const mountPage = (
  pageComponent: VuePageComponent,
  pageProps: Record<string, any>,
  pageContainer: UniNode
) => {
  const vnode = createVNode(pageComponent, pageProps)
  // store app context on the root VNode.
  // this will be set on the root instance on initial mount.
  vnode.appContext = appContext
  ;(vnode as any).__page_container__ = pageContainer
  render(vnode, pageContainer as unknown as Element)
  const publicThis = vnode.component!.proxy!
  ;(publicThis as any).__page_container__ = pageContainer
  return publicThis
}

在上述代码中,__pageInstance 被作为 pageProps 传递给 mountPage 方法。当执行 mountPage 后,pageProps 会被添加到组件的上下文 context 对象中 attrs 属性中。

在前面的代码中,虽然没有直接看到对 eventChannel 对象的使用,但它主要用于参数传递。回顾前面提到的使用方法部分,我们注意到在页面中通过 proxy.getOpenerEventChannel() 来获取 eventChannel 实例,也就是说,getOpenerEventChannel 是在页面实例上添加的。那么,它是何时被添加到实例上的呢? setupPage 中。

setupPage 方法代码如下:

export function setupPage(component: VuePageComponent) {
  const oldSetup = component.setup
  component.setup = (props, ctx) => {
    const {
      attrs: { __pageId, __pagePath, /*__pageQuery,*/ __pageInstance },
    } = ctx
    const instance = getCurrentInstance()!
    const pageVm = instance.proxy!
    initPageVm(pageVm, __pageInstance as Page.PageInstance['$page'])
    if (pageVm.$page.openType !== 'openDialogPage') {
      addCurrentPage(
        initScope(
          __pageId as number,
          pageVm,
          __pageInstance as Page.PageInstance['$page']
        )
      )
    }
    if (oldSetup) {
      return oldSetup(props, ctx)
    }
  }
  return component
}

export function initScope(
  pageId: number,
  vm: ComponentPublicInstance,
  pageInstance: Page.PageInstance['$page']
) {
  //
  vm.getOpenerEventChannel = () => {
    if (!pageInstance.eventChannel) {
      pageInstance.eventChannel = new EventChannel(pageId)
    }
    return pageInstance.eventChannel as EventChannel
  }
  return vm
}

setupPage 通过重写组件的 setup 函数,同时保留组件的默认 setup 功能,这正是面向切面编程的一个典型示例。

initScope 方法中,getOpenerEventChannel 被添加到了 vm 实例对象上,这正是我们能够通过 proxy 访问到 getOpenerEventChannel 的原因

至此,我们已经基本掌握了 EventChannel 在页面间通信的使用方法及其实现原理。

Last Updated: 9/1/2024, 8:19:59 PM