背景
最近团队正在开发一个小程序商城的项目,在开发过程中,使用微信小程序自带的路由相关接口时,发现有些不便之处,如下问题:
- 参数的传递方式不太友好,仅支持
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
异步函数调用
路由封装
首先我们已经确定小程序路由封装核心是 路由配置管理 和 封装路由方法,接下来就是对小程序路由封装。
路由接口设计
小程序提供路由方法有五个:switchTab
、navigateTo
、navigateBack
、redirectTo
、reLaunch
, 借鉴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.push
、route.replace
、``route.tab、
route.relaunch、
route.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
的路由配置,目前我们项目使用的此方案
- 在项目中我把
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',
};
- 接下来通过
gulp
把app.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));
总结
其实小程序的路由跳转有很多方法,选择合适的路由跳转方式会提高用户体验,封装主要是提升开发效率,减少后期维护成本