【小程序实战】- 借鉴 vue-router 封装小程序路由工具

1,745 阅读9分钟

背景

最近团队正在开发一个小程序商城的项目,在开发过程中,使用微信小程序自带的路由相关接口时,发现有些不便之处,如下问题:

  • 参数的传递方式不太友好,仅支持 string类型,只能通过拼接方式组合url
  • 页面栈最多十层,使用wx.navigateTo时,当前页面层级超过10级则无法跳转
  • 当路由发生更改时,则需要全局搜索替换新的路由url,却容易造成忘改,维护成本高
  • 需要开发时区分当前路由是否 tabBar页面,然后选择navigateTo,还是使用switchTab方法

那遇到以上问题,我们改如何封装路由文件和路由方法,提升小程序体验和开发效率呢?

借鉴 vue-router 路由的思想和使用方式,进行路由配置管理和二次封装

vue-router 路由

上述提到了借鉴 vue-router 路由的思想和 API 使用方式,来解决小程序路由跳转和参数等存在的问题呢?

相信大家在开发vue项目,使用 vue-router 路由工具首先都需要先进行路由配置、创建路由实例、最后调用路由跳转方法。让我们先看看 vue-router路由是如何使用的

首先、进行路由配置和创建路由实例

import { createRouter, createWebHistory } from 'vue-router'
import Home from '../views/Home.vue'

const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home,
    meta: {
      title: '首页'icon: 'icon-home'
    }
  },
  {
    path: '/about',
    name: 'About',
    // route level code-splitting
    // this generates a separate chunk (about.[hash].js) for this route
    // which is lazy-loaded when the route is visited.
    component: () => import(/* webpackChunkName: "about" */ '../views/About.vue')
  }
]

const router = createRouter({
  history: createWebHistory(process.env.BASE_URL),
  routes
})

export default router

然后、调用路由跳转方法,进行页面跳转

import { useRoute } from 'vue-router';

const route = useRoute();

// route by route path
route.push('home');

// object
route.push({ path: 'home' });

// named route navigation with parameters
route.push({ name: 'user', params: { userId: '123' } });

// with query params, resulting in /profile?info=some-info
route.push({ path: 'profile', query: { info: 'some-info' } });

// promise async await
await route.push({ path: 'home' });

由此可见基于vue-router路由这种模式,

  • url参数是对象形式配置
  • 路由跳转方式使用路由path或路由name进行跳转
  • 路由配置方式可扩展性强,比如我们当路由是什么类型页面
  • 路由跳转方法支持promise 异步函数调用

路由封装

首先我们已经确定小程序路由封装核心是 路由配置管理封装路由方法,接下来就是对小程序路由封装。

路由接口设计

小程序提供路由方法有五个:switchTabnavigateTonavigateBackredirectToreLaunch, 借鉴vue-router路由跳转的API方式来设计我们的路由API

封装路由 API原生路由 API描述
route.go(string | object)不同类型路由跳转提供type路由参数,支持不同类型路由跳转
route.push(string | object)wx.navigateTo(object)保留当前页面,跳转到应用内的某个页面。但是不能跳到 tabbar 页面。使用wx.navigateBack可以返回到原页面。小程序中页面栈最多十层
route.tab(string | object)wx.switchTab(object)跳转到 tabBar 页面,并关闭其他所有非 tabBar 页面
route.replace(string | object)wx.redirectTo(object)关闭当前页面,跳转到应用内的某个页面。但是不允许跳转到 tabbar 页面
route.relanuch(string | object)wx.reLaunch(object)关闭所有页面,打开到应用内的某个页面

路由配置管理

接下来我们开始设计路由配置,首先先定义小程序路由配置项,如下:

/**
 * 小程序路由配置项
 */
export type RouteRecord = {
  /**
   * @description 路由路径 URL
   */
  url?: string,
  /**
   * @description 路由唯一标识 eg: Home, Logs
   */
  name?: string,
  /**
   * @description 路由别名名称
   */
  title?: string,
  /**
   * @description 是否 tabBar 路由页面
   */
  tabBar?: boolean,
  /**
   * @description 附加自定义数据
   */
  meta?: Record<string | number | symbol, unknown>,
};

然后,配置小程序路由:

/**
 * 小程序路由配置
 */
export const routes: RouteRecordRaw[] = [
  {
    name: 'Home',
    title: '首页',
    url: '/pages/home/index',
    tabBar: true,
  },
  {
    name: 'ShoppingCart',
    title: '购物车',
    url: '/pages/shopping-cart/index',
    tabBar: true,
  },
  {
    name: 'Mine',
    title: '我的',
    url: '/pages/mine/index',
    tabBar: true,
  },
  {
    name: 'GoodsDetail',
    title: '商品详情页',
    url: '/pages/goods-detail/index',
  },
  ...
]

其实应该大家早就发现了,这路由配置跟vue-router一模一样~🐶!!

封装路由方法

上述,我们已经定义好小程序路由 API 和 路由配置,接下来就是开始封装路由工具,基于vue-router-next源码中的router.js来设计路由实例,如下:

/**
 * Creates a Router Matcher
 */
export function createRouterMatcher(routes: RouteRecordRaw[]) {
  const matcherMap = new Map<RouteRecordName, Partial<RouteRecord>>();
  return matcherMap;
}

/**
 * Creates a Router instance
 */
export function createRouter(options: RouterOptions) {
  const { routes } = options;
  const matcher = createRouterMatcher(routes);

  async function go(to: RouteNavigateAction | string, query?: RouteQuery): Promise<RouteNavigatCallbackResult> {}

  async function push(navigate) {}

  async function tab(navigate) {}

  async function replace(navigate) {}

  async function relaunch(navigate) {}

  async function back(navigateBack) {}

  return {
    routes,
    matcher,
    go,
    push,
    tab,
    replace,
    relaunch,
    back,
  };
}

createRouter 创建路由实例

路由实例,是通过执行createRouter(options)方法创建的,这点是参考VueRouter。把配置好的路由配置项传入路由实例中,这样我们可以根据路由配置进行路由别名跳转、路由类型判断和参数解析等

import routes from './config/router';

const route = createRouter({
  routes,
});

route.go({ name: 'home' });

createRouterMatcher 创建路由匹配

创建路由匹配createRouter(routes),参数是路由配置数组,遍历routes,以路由标识为key值,当前路由配置为 value值,将路由配置转化为matcher,后续我们可以通过路由标识name来找到当前的路由url

export function createRouterMatcher(routes: RouteRecordRaw[]) {
  const matcherMap = new Map<RouteRecordName, Partial<RouteRecord>>();

  routes.forEach((route) => {
    route.name && matcherMap.set(route.name, route);
  });

  return matcherMap;
}

接来下就是实现路由跳转的核心方法封装

路由跳转封装

根据背景里提到的小程序路由的一些问题,我们可以做一个简单的task列表;

  • 满足路由标识 name参数进行路由跳转
  • 支持 query 对象方式进行 url 参数传递
  • 区分 tabBar页面,自动选择navigateTo / switchTab方法
  • 解决页面层级超过10级则无法跳转
  • 支持传入string类型参数的路由路径
支持路由标识跳转

之前已经创建了createRouterMatcher路由匹配,所以当传入的参数是路由标识name值,我们可以根据matcher.ge(key)方法来查找当前路由配来类获取路由url

/**
 * 根据路由标识 name 获取当前路由 url
 */
function getNavigateUrl(navigate: BaseRouteNavigateOption): string {
  if (navigate.name && !navigate.url) {
    const url = matcher.get(navigate.name)?.url;
    if (!!url) return url;
    throw new Error('page route is not found');
  }

  if (!navigate?.url) {
    throw new Error('page route is not found');
  }

  return navigate.url as string;
}
async function go(to: RouteNavigateOption): Promise<RouteNavigatCallbackResult> {
  const url = getNavigateUrl(to); // 获取路由 url
  to.url = url;
  return await wx.navigateTo(to);
}

// go({ name: 'Home' }) => //  wx.navigateTo('/pages/home/index')
支持 query 对象参数

query对象进行解析,把解析好的参数拼接到路由url上,eg: ${url}?${serializeQuery(query)

/**
 * Parse URL query parameters
 */
function serializeQuery(obj: RouteQuery = {}) {
  return Object.keys(obj)
    .map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(obj[key])}`)
    .join('&');
}
async function go(to: RouteNavigateOption): Promise<RouteNavigatCallbackResult> {
  const url = getNavigateUrl(to); // 获取路由 url
  const query = serializeQuery(to?.query); // 解析quey参数

  to.url = url.indexOf('?') >= 0 ? `${url}${query}` : `${url}?${query}`;

  return await wx.navigateTo(to);
}

// go({ url: '/pages/goods-detail/index', query: { id: 123 } }) => wx.navigateTo('/pages/goods-detail/index?id=123')
区分 tabBar,选择navigateTo / switchTab方法

当我们在路由配置项中的,通过设置tabBar选项来区分是否 tabBar页面。当传入的参数是name路由标识,则直接从路由匹配表查找对应当前路由项,如果传入的参数是url路由路径,则需要循环遍历匹配当前url进行查找。最后判断当前路由是否tabBar页面

/**
 * 判断是否 tabBar 路由页面
 */
function hasTabBarNavigate(navigate: BaseRouteNavigateOption): boolean {
  let isTabBar = false;

  if (navigate?.name) {
    isTabBar = !!matcher.get(navigate.name)?.tabBar;
  }

  if (navigate?.url) {
    matcher.forEach((item) => {
      const url = navigate?.url?.split('?')[0];

      if (item.url === url) {
        isTabBar = !!item?.tabBar;
      }
    });

    return isTabBar;
  }

  return isTabBar;
}
async function go(to: RouteNavigateOption): Promise<RouteNavigatCallbackResult> {
  const url = getNavigateUrl(to); // 获取路由 url
  const query = serializeQuery(to?.query); // 解析quey参数

  to.url = url.indexOf('?') >= 0 ? `${url}${query}` : `${url}?${query}`;

  if (hasTabBarNavigate(to)) {
    const navigateTab = assign(navigate, {
      url: navigate.url.split('?')[0], // wx.switchTab: url 不支持 queryString
    });
    return await wx.switchTab(navigateTab);
  }

  return await wx.navigateTo(to);
}
解决页面层级超过10级则无法跳转

因为小程序页面栈最多十层,所以我们可以根据页面层级数来进行判断,使用不同路由 api 接口

  • 当页面层级小于10,则直接跳转;
  • 当页面层级大于等于10,若页面存在于页面栈中,回退到对应的页面栈;不存在,关闭当前页面,跳转到新页面

总结下,每次跳转判断当前页面层级数,然后先去页面栈中查找目标页面是否已经访问过,如果没有访问过则判断页面栈中是否已经有 10 个页面,有则用wx.redirectTo,没有则wx.navigateTo,如果访问过则用wx.navigateBack返回。

基本核心代码逻辑就这样多了,是不是感觉很简单~🐶

async function go(to: RouteNavigateOption | string): Promise<RouteNavigatCallbackResult> {
  const navigate = asNavigateObject(to);
  const url = getNavigateUrl(to); // 获取路由 url
  const query = serializeQuery(to?.query); // 解析quey参数

  const maxDeep = 10; // 页面栈最大深度
  const pageStack = getCurrentPages();

  navigate.url = url.indexOf('?') >= 0 ? `${url}${query}` : `${url}?${query}`;

  // 页面栈已达上限
  if (pageStack.length >= maxDeep) {
    const curDelta = findPageInHistory(navigate.url); // 查询当前路由是否存在页面栈中

    // 当前页面:在页面栈中
    if (curDelta > -1)
      return back({
        delta: pageStack.length - curDelta,
        data: navigate?.query,
      });

    // 当前页面:不在页面栈中
    return wx.redirectTo(navigate);
  }

  // tabBar 页面 or switchTab 路由
  if (hasTabBarNavigate(navigate) || navigate?.type === RouteType.SWITCH_TAB) {
    const navigateTab = assign(navigate, {
      url: navigate.url.split('?')[0], // wx.switchTab: url 不支持 queryString
    });
    return await wx.switchTab(navigateTab);
  }

  // redirectTo 路由
  if (navigate?.type === RouteType.REDIRECT_TO) {
    return await wx.redirectTo(navigate);
  }

  // reLaunch 路由
  if (navigate?.type === RouteType.RELAUNCH) {
    return await wx.reLaunch(navigate);
  }

  // navigateTo 路由
  return await wx.navigateTo(navigate);
}

剩下的,就是对route.pushroute.replace、``route.tabroute.relaunchroute.back`的方法简单进行封装下就完成小程序路由工具。

async function push(to: RouteNavigateOption | string) {
  return await go(assign(to, { type: RouteType.NAVIGATE_TO }));
}

async function tab(to: RouteNavigateOption | string) {
  return await go(assign(to, { type: RouteType.SWITCH_TAB }));
}

async function replace(to: RouteNavigateOption | string) {
  return await go(assign(to, { type: RouteType.REDIRECT_TO }));
}

async function relaunch(to: RouteNavigateOption | string) {
  return await go(assign(to, { type: RouteType.RELAUNCH }));
}

async function back(to: RouteNavigateBackOption | number) {
  const pageStack = getCurrentPages();
  const { delta = 1, data = {} } = typeof to === 'number' ? { delta: to } : to;

  // 设置上一页面的数据
  if (Object.keys(data).length > 0) {
    const backPage = pageStack[pageStack.length - 1 - delta];
    backPage?.setData(data);
  }

  return await wx.navigateBack({ delta });
}

在页面中使用,可以愉快的像使用vue-router路由方式进行对路由跳转、设置返回等

import route form './config/router.ts';

Page({
  data: {},

  // 跳转商品详情页
  onClickGoodsItem(e) {
    const id = e.detail.id
    route.go({ name: 'GoodsDeatil', query: { id }})
  },

  // 跳转搜索页
  onClickSearchItem() {
    route.push({ url: '/pages/search/index' })
  },

  // 返回上一页
  onBack() {
    route.back(1); // or route.back({ delta: 1 })
  }
});

小技巧

当遇到小程序路由页面比较多的话,那这样就会导致需要编写很多路由配置表,而也会增加路由配置表的文件体积,对于追求性能优化的话,这是极大不友好的。而且还有同时维护app.json页面配置,相对来说比较麻烦

所以路由配置表可以只填写tabBar页面的路由项,这样可以减少配置表文件体积,也能路由跳转自动选择自动选择navigateTo / switchTab方法。但注意点是其他非tabBar页面,则不能使用路由name的标识进行跳转,其他这点影响不算太

如果当前项目是基于gulp作为编译构建打包,且路由配置表不算太多的话,其实可以结合路由配置表,动态生成app.json的路由配置,目前我们项目使用的此方案

  1. 在项目中我把app.json命名成app.json.ts,这样为了防止已项目app.ts重名。
// app.json.ts
import router from './config/router';

/**
 * 重写路由路径
 */
const rewriteRouteUrl = (url = '', fileRoot = '') => {
  if (fileRoot) {
    const reg = new RegExp(`/${fileRoot}/`, 'i');
    return url.replace(reg, '');
  }
  return url.replace(/\//i, '');
};

/**
 * 获取小程序路、分包路由配置、tabBar导航配置
 * @param routes 小程序路由配置
 */
const getRoutes = (routes: RouteRecordRaw[]) => {
  const appRoutes: AppRoutes = { pages: [], subpackages: [], tabBarList: [] };

  return routes.reduce(({ pages, subpackages, tabBarList }, route) => {
    if (route?.root && Array.isArray(route?.pages)) {
      subpackages.push({
        root: route?.root,
        pages: route?.pages.map((item: RouteRecordRaw) => rewriteRouteUrl(item.url, route.root)),
      });
    } else {
      pages?.push(rewriteRouteUrl(route.url));
    }

    if (route?.tabBar) {
      const { iconPath, selectedIconPath } = route?.meta || {};

      tabBarList.push({
        text: route?.title,
        pagePath: rewriteRouteUrl(route?.url),
        iconPath,
        selectedIconPath,
      });
    }

    return {
      pages,
      subpackages,
      tabBarList,
    };
  }, appRoutes);
};

const { pages, subpackages, tabBarList } = getRoutes(router);

export default {
  pages,
  subpackages,
  window: {
    navigationStyle: 'default',
    backgroundTextStyle: 'light',
    navigationBarBackgroundColor: '#fff',
    navigationBarTitleText: '',
    navigationBarTextStyle: 'black',
  },
  tabBar: {
    color: '#333333',
    selectedColor: '#ff7437',
    borderStyle: 'white',
    list: tabBarList,
  },
  sitemapLocation: 'sitemap.json',
};
  1. 接下来通过gulpapp.json.ts编译输出app.json文件。 这里就简单大概说下思路:主要通过webpack编译打包输出代码,然后使用eval模块执行js代码,把执行后的代码进行恢复成文件流输出,最后修改下文件后缀名
const eval = require('eval');
const webpack = require('webpack-stream');
const named = require('vinyl-named');

/**
 * toJson task
 * 编译ts 转换 json
 */
const toJson = () => src(globs.jsonts, { since: since(toJson) })
  .pipe(named(file => file.relative.slice(0, -path.extname(file.path).length)))
  .pipe(webpack({
    mode: NODE_ENV,
    resolve: {
      alias: aliasOptions,
      extensions: ['.ts', '.js'],
    },
    output: {
      libraryTarget: 'commonjs',
    },
    module: {
      rules: [
        {
          test: /\.(js|ts)?$/,
          loader: 'esbuild-loader',
          options: {
            loader: 'ts',
            target: 'es2015',
            tsconfigRaw: require('../tsconfig.json'),
          },
          exclude: /(node_modules)/,
        },
      ],
    },
  }))
  .pipe(tap((file) => {
    const res = eval(file.contents.toString());
    const str = JSON.stringify(res.default || res, null, 2);
    file.contents = Buffer.from(str, 'utf8'); // string恢复成文件流
  }))
  .pipe(rename({ extname: '' }))
  .pipe(dest(distDir));

总结

其实小程序的路由跳转有很多方法,选择合适的路由跳转方式会提高用户体验,封装主要是提升开发效率,减少后期维护成本