统一路由,让小程序跳转更智能

2,624 阅读5分钟

背景

我们在小程序开发及运营过程中,不可避免的需要进行页面之间的跳转。如果使用小程序自带的路由功能来实现这个功能,是非常简单的,如:

// 根据不同的场景选择 navigateTo、redirectTo、switchTab 等
wx.navigateTo({
  url: "pages/somepage?id=1",
  success: function (res) {},
});

但这里面存在几个问题:

  • 需要代码里面写死或者运营人员维护小程序页面的长长的具体路径,这显然是很不友好的
  • 需要知道页面是否为 tabbar 页面(switchTab)
  • 如果某个页面在 tabbar 和非 tabbar 页面之间发生了变化,或路径因为重构、主包瘦身等各种原因发生变化,原来的代码就会报错导致无法运行
  • navigateBack 不支持传参

为了解决以上问题,我们在项目中实现了一套基于命令别名(cmd)的统一路由跳转方式(以下称为统一路由),很好解决了遇到的实际问题,统一路由特点如下:

  • 页面别名声明使用注释方式,不侵入业务代码
  • 页面可以存在多个别名,方便新老版本页面的流量切换
  • 路由内自动判断是否 tabbar 页面,自行处理跳转及传参,业务代码无需关心
  • 支持纯 js api 的页面跳转及需要用户点击的任意类型跳转(如联系客服、打开小程序等等)
  • 对于页面栈中存在相同页面时,可以自动返回并根据参数是否相同决定是否需要刷新页面,可有效减少页面栈层级,规避小程序 10 层限制

实现思路

step1. 资源描述约定

小程序内的跳转类操作存在以下几种

  1. js api 直接可以操作的内部页面间跳转(wx.navigateTo、wx.navigateBack、wx.redirectTo、wx.reLaunch、wx.switchTab)
  2. js api 直接可以操作的打开微信原生功能的跳转(扫码、拨打电话等)
  3. 需要借助点击操作的跳转(如打开小程序及客服等需要 open-type 配合的场景 )

针对这三类操作,我们使用常见的 URL(统一资源定位系统)方式描述不同的待跳转资源

  1. 内部页面
https://host?cmd=${pagename}&param1=a  // 打开普通页面并传参,标准的H5容器也算在普通页面内
  1. 微信原生 API
https://host?cmd=nativeAPI&API=makePhoneCall&phoneNumber=123456  // 拨打电话
https://host?cmd=nativeAPI&API=scanCode&callback=scanCallback // 扫码并执行回调
  1. 需要借助按钮 open-type 的微信原生能力
https://host?cmd=nativeButtonAPI&openType=contact  // 在线客服
  1. 打开另一个小程序
https://host?cmd=miniProgram&appId=wx637bb****&path=pages/order/index&version=trial&uid=${uid} 

小程序跳转需要携带更多的参数,所以做了cmd的区分,这里实际会解析成 nativeButtonAPI 运行

step2. 在页面内定义需要的数据

在每个页面的顶部添加注释,注意 cmd 不能重复,支持多个 cmd。为了方便后续解析,我们的注释大体上遵循 JSDoc 注释规范

// pages/detail/index.tsx

/**
 * @cmd detail, newdetail
 * @description 详情
 * @param skuid {number} skuid
 */

step3. 在编译阶段扫描并生成配置文件

根据入口文件的页面定义,匹配出需要的注释部分,使用 doctrine 解析需要的数据,解析后的数据如下:

// config/router.config.ts
export default {
  index: {
    description: "首页", // 页面描述
    path: "/pages/index/index", // 真实路径
    isTabbar: true, // 是否tabbar页面
    ensureLogin: false, // 是否需要强制登录
  },
  detail: {
    description: "详情",
    path: "/pages/detail/index",
    isTabbar: false,
    ensureLogin: true,
  },
};

这里顺便可以使用 param 等生成详细的页面名称及入参文档,提供给其他研发或运营同学使用。

step4. 资源描述解析为标准数据

根据上面的资源描述约定及扫描得到的配置文件,我们可以将其转换为方便在小程序内解析的数据定义,基本格式如下

{
    origin: 'https://host?cmd=detail&skuid=1',  // 原始数据
    parsed: {
        type: 'PAGE',  // 类型,PAGE,NATIVE_API,NATIVE_BUTTON_API,UNKNOW
        data: {
            path: 'pages/detail/index',  // 实际的页面路径,如果type是PAGE则会解析出此字段
            action: undefined, // 动作,scanCode,makePhoneCall,openType,miniprogram ……。如果type是NATIVE_API,NATIVE_BUTTON_API,则会解析出此字段
            params: {
                skuid: '1'  // 需要携带的参数
            }
        }
    }
}

step5. 根据标准数据执行对应逻辑

由于我们的项目使用的是 Taro 框架,以下伪代码都是以 Taro 为例。

// utils/router.ts

// 用于解析原始链接为标准数据
const parseURL = (origin) => {
  // balabala,一顿操作格式化成上文的数据
  const data = {
      ...
  };
  return data;
};

// 执行除 NATIVE_BUTTON_API 之外的跳转
const routeURL = (origin) => {
    const parsedData = parseURL(origin)
    const {parsed: {type, data}} = parsedData

    switch(type){
        case 'PAGE':
            ...
            break;
        case 'NATIVE_API':
            ...
            break;
        case 'UNKNOW':
            ...
            break;
    }
};

export default {
  parseURL,
  routeURL,
};

对于需要点击的类型,我们需要借助 UI 组件实现

// components/router.tsx

import router from "/utils/router";
import { Button } from "@tarojs/components";
import Taro, { Component, eventCenter } from "@tarojs/taro";

export default class Router extends Component {
  componentWillMount() {
    const { path } = this.props;
    const data = router.parseURL(path);
    const { parsed, origin } = data;
    const openType =
      (parsed &&
        parsed.data &&
        parsed.data.params &&
        parsed.data.params.openType) ||
      false;
    this.setState({
      parsed,
      openType,
    });
  }

  // 点击事件
  async handleClick(parsed, origin) {
    // 点击执行动作
    let {
      type,
      data: { action, params },
    } = parsed;
    if (!type) {
      return;
    }

    // 内部页面
    if (["PAGE", "CMD_UNKNOW"].includes(type)) {
      console.log(`CMD_NATIVE_PAGE 参数:`, origin, options);
      router.routeURL(origin);
      return;
    }

    // 拨打电话、扫码等原生API
    if (["NATIVE_API"].includes(type) && action) {
      if (action === "makePhoneCall") {
        let { phoneNumber = "" } = params;
        if (!phoneNumber || phoneNumber.replace(/\s/g, "") == "") {
          Taro.showToast({
            icon: "none",
            title: "未查询到号码,无法呼叫哦~",
          });
          return;
        }
      }

      let res = await Taro[action]({ ...params });

      // 扫码事件,需要在扫码完成后发送全局广播,业务内自行处理
      if (action === "scanCode" && params.callback) {
        let eventName = `${params.callback}_event`;
        eventCenter.trigger(eventName, res);
      }
    }

    // 打开小程序
    if (
      ["NATIVE_BUTTON_API"].includes(type) &&
      ["miniprogram"].includes(action)
    ) {
      await Taro.navigateToMiniProgram({
        ...params,
      });
    }
  }

  render() {
    const { parsed, openType, origin } = this.state;

    return (
      <Button
        onClick={this.handleClick.bind(this, parsed, origin)}
        hoverClass="none"
        openType={openType}
      >
        {this.props.children}
      </Button>
    );
  }
}

在具体业务中使用

// pages/index/index.tsx
import router from "/utils/router";
import Router from "/components/router";

// js方式直接跳转
router.routeURL('https://host?cmd=detail&skuid=1')

// UI组件方式
...
render(){
    return <Router path='https://host?cmd=detail&skuid=1'></Router>
}
...

当然这里面可以附加你自己需要的功能,比如:增加跳转方式控制、数据处理、埋点、加锁防连续点击,相对来说并不复杂。甚至你还可以顺手实现一下上面提到的 navigateBack 传参。

结语

上文的思考及实现过程比较简单,纯属抛砖引玉,欢迎大家交流互动。

我所在的团队主要负责 SaaS 化微信小程序的研发,自 18 年开始,进行了较多业务之外的技术迭代,也积累了不少小程序领域的经验与解决方案。如:微信小程序的继续集成、SaaS 产品的批量自动上线交付、多租户的千店千面装修、多租户多应用配置中心、日志监控与告警、深度性能优化等等。这些日常积累也会计划发布出来,和各位道友交流切磋 :D