我们都知道,小程序开发一个页面,首先要在 pages 文件夹在新建文件夹,然后在 appConfig 中配置页面完整的路径地址,在进行路由跳转时,还需要在 navigateTo 中写下完整的页面路径。当页面数量少,代码量小时,似乎可以接受,但当代码量大,项目存在好几个分包时,页面跳转路径会变得非常长,书写页面路径会变得越来越困难,代码也不够美观和直观。解析路由参数时,还需要经过几步转换才能得到。。
环境
基础环境: taro 3.0.5 / react 16.10.0 / typescript 4.1.0
技术栈: React Hooks
路由部分
Taro 小程序路由方法调用示例
路由跳转
navigateTo({
url: `/package-appointment/pages/manage-appointments/index?roomId=${roomId}&appointmentId=${appointmentId}&scriptId=${scriptId}`
})
参数解析
const { params } = useRouter()
const { roomId, appointmentId, scriptId } = params
const oRoomId = Number.parseInt(roomId)
const oAppointmentId = Number.parseInt(appointmentId)
const oScriptId = Number.parseInt(scriptId)
优化方案
PART 1 路由表文件
新建一个路由表文件 routeTable.ts,为路径命名。
export enum URLs {
INDEX = '/pages/index/index',
MANAGE_APPOINTMENTS = '/package-appointment/pages/manage-appointments/index',
INTENT_DETAIL = '/package-intent/pages/intent-detail/index'
}
调用时,只需要引入路由表文件
import { URLs } from '@/common/routeTable'
...
navigateTo({
url: `${URLs.MANAGE_APPOINTMENTS}?roomId=${roomId}&appointmentId=${appointmentId}&scriptId=${scriptId}`
})
...
引入了路由表文件的同时,引入了新的问题,新建一个页面要维护两个路由地址文件,一个是 app.config ,另一个就是路由表文件 routeTable。增加了维护成本,同时两个文件,只要有一点错误,页面就不能正确的被访问。怎么处理这个问题?
PART 2 脚本更新路由表与相关配置
无论 app.config 路径配置还是路由表配置,页面与路径是对应关系,我们可以写一个脚本去处理问题。首先扫描源码文件夹,通过正则表达式找到 pages 和 package- 下的所有页面。找到文件后,获取文件夹名称作为路由表的 shortName, 构造数据后即可替换和新建到 app.config 和 routeTable 中。
除此之外,还可以顺带把 project.config.json 中 miniprogram 字段更新,用于开发页面进行快捷调试。
脚本完成之后,每次开发页面时,只需要在 pages 目录下或者分包 pages 目录下新建目录,运行脚本命令即可自动更新 app.config 、 project.config 和 routeTable。
注意:
- 查找页面文件,默认 pages 下的一级目录为所有页面文件,这里需要开发者拥有良好的开发习惯,写的页面没有进行嵌套。
- 脚本代码很简单,就不新建仓库了,完整代码已经贴到了最下面
PART 3 地址参数改造
观察 PART 1 部分的代码,其实可以看到,虽然通过路由表形式缩短了一部分地址的长度,但是参数部分还可以做些文章。
// queryParams 封装
const covertObjToSearchParams = (searchObj: commonObjectProps = {}) => {
let searchParams = ''
for (let i in searchObj) {
searchParams = searchParams + `${i}=${searchObj[i]}&`
}
return searchParams.slice(0, -1)
}
const queryParams = covertObjToSearchParams({ roomId, appointmentId, scriptId })
navigateTo({
url: `${URLs.MANAGE_APPOINTMENTS}?${queryParams}`
})
通过这种方式传递参数,简洁直观,方便不少。
PART 4 路由跳转方法改造
小程序中的方法大都都是通过一个对象传递的参数,这是因为大多数方法都有很多参数,通过对象传参则显得代码整洁友好。观察我们的代码,用到的最多的就是一个 url 参数, 那我们完全可以直接将地址放到第一个参数位上。
navigateTo 方法改造
import { navigateTo as originNavigateTo } from '@tarojs/taro'
export function navigateTo(url: string, params?: commonObjectProps, opt?: commonObjectProps) {
if (!params) return originNavigateTo({ url, ...opt })
const _url = `${url}?${covertObjToSearchParams(params)}`
originNavigateTo({ url: _url, ...opt })
}
navigateTo 调用
navigateTo(URLs.MANAGE_APPOINTMENTS, { roomId, appointmentId, scriptId })`
相比前文中的最开始时的路由调用,这里更加简洁明了
PART 5 路由参数解析方法改造
Taro 中提供了 useRouter hooks去解析路由参数,但该 Hooks 解析出的参数都是字符串类型。
当使用参数时,需要先导出 params 对象,然后再拿到参数值,对于一些数字、布尔类型值还只能再通过转换获得原始值。流程繁琐且复杂。怎么解决这个问题?
首先对 useRouter 进行初步封装
import { useRouter as useOriginRouter } from '@tarojs/taro'
export function useRouter<T>(): T {
const { params } = useOriginRouter()
return params
}
封装完的 useRouter 只简化了导出 params 的过程,而参数类型转换还是只能根据具体的参数进行具体转换。那这样的封装其实还是有点鸡肋。
通过 queryParams 传递的参数只能是字符串形式的,所以解析参数也只能解析出字符串。因而可以通过 JSON.stringify 将参数对象转换为字符串,解析时再利用 JSON.parse 解析出保有原始数据类型的参数。
import { useRouter as useOriginRouter, navigateTo as originNavigateTo } from '@tarojs/taro'
// 路由跳转
export function navigateTo(url: string, params?: object) {
if (!params) return originNavigateTo({ url })
const _url = `${url}?navParams=${JSON.stringify(params)}`
originNavigateTo({ url: _url })
}
// 路由解析
export function useRouter<T>(): T {
const { params } = useOriginRouter()
try {
const targetParams = params['navParams']
if (targetParams) return JSON.parse(targetParams)
} catch (e) {
console.warn('参数解析失败', e)
}
return {} as T
}
使用这种封装方式,虽然 URL 不直观且不利于 SEO,但在小程序环境中,用户根本不会看到 URL,且在当前小程序环境中,SEO似乎根本没什么用。
使用我们封装好的代码,文章开头给出的示例可简化为如下代码:
import { navigateTo, useRouter } from '@/app'
navigateTo(URLs.MANAGE_APPOINTMENTS, { roomId, appointmentId, scriptId })
...
const { roomId, appointmentId, scriptId } = useRouter()
...
完整路由脚本生成和代码
脚本代码很简单,就不新建仓库存了。
主脚本文件
const path = require('path')
const fs = require('fs')
const { isDir, readDir, readFile, writeFile } = require('./util')
const PROJECT_CONFIG = require('../project.config.json')
const BASE_PATH = path.resolve(__dirname, '..')
const APP_CONFIG_PATH = path.resolve(BASE_PATH, 'src/app.config.ts')
const URL_TABLE_PATH = path.resolve(BASE_PATH, 'src/common/url.constant.ts')
const PACKAGE_CONFIG_PATH = path.resolve(BASE_PATH, 'project.config.json')
function handleMainPageConfig(dirsName, fileStr) {
const paths = dirsName.map((dir) => `'pages/${dir}/index'\n`)
fileStr = fileStr.replace(/pages[\S\s]*subPackages/, `pages: [${paths}], subPackages`)
return fileStr
}
function handleSubPageConfig(rootPath, dirsName, subPackage) {
const [, name] = rootPath.split('-')
subPackage.push({
root: rootPath,
name,
pages: dirsName.map((name) => `pages/${name}/index`),
})
return subPackage
}
function handleMainRouteTable(dirsName, fileStr) {
const paths = dirsName.map((dir) => `'/pages/${dir}/index'\n`)
for (let i = 0; i < dirsName.length; i++) {
const current = dirsName[i]
const name = current
.split('-')
.map((v) => v.toUpperCase())
.join('_')
fileStr = fileStr + `${name}=${paths[i]},`
}
return fileStr
}
function handleSubRouteTable(rootPath, dirsName, fileStr) {
const paths = dirsName.map((dir) => `'/${rootPath}/pages/${dir}/index'\n`)
for (let i = 0; i < dirsName.length; i++) {
const current = dirsName[i]
const name = current
.split('-')
.map((v) => v.toUpperCase())
.join('_')
fileStr = fileStr + `${name}=${paths[i]},`
}
return fileStr
}
function handleMainProjectConfig(dirsName, routes) {
const paths = dirsName.map((dir) => `pages/${dir}/index`)
return routes.concat(paths.map((_path, idx) => {
return {
id: idx + 1,
name: dirsName[idx],
pathName: _path,
query: '',
}
}))
}
function handleSubProjectConfig(rootPath, dirsName, routes) {
const paths = dirsName.map((dir) => `${rootPath}/pages/${dir}/index`)
return routes.concat(
paths.map((_path, idx) => {
return {
id: idx + 1,
name: dirsName[idx],
pathName: _path,
query: '',
}
}),
)
}
async function updateAppConfig(fileStr, subPackage) {
fileStr = fileStr.replace(/(subPackages[\S\s]*)window/g, `subPackages: ${JSON.stringify(subPackage)}, window`)
await writeFile(APP_CONFIG_PATH, fileStr)
}
async function updateRouteTable(fileStr) {
let routeTable = `
// 自动生成, pages 文件夹新建目录,运行 npm run g
export enum URLs {
${fileStr}
}
`
await writeFile(URL_TABLE_PATH, routeTable)
}
async function updateProjectConfig(entrance) {
PROJECT_CONFIG.condition.miniprogram.list = entrance
await writeFile(PACKAGE_CONFIG_PATH, JSON.stringify(PROJECT_CONFIG, null, 2))
}
function sortDirs(dirsName) {
const indexIdx = dirsName.findIndex((dirname) => dirname === 'index')
if (indexIdx === -1) return dirsName.sort((a, b) => a.localeCompare(b))
dirsName.splice(indexIdx, 1)
dirsName = dirsName.sort((a, b) => a.localeCompare(b))
dirsName.unshift('index')
return dirsName
}
async function main(dirPath) {
const baseDirsName = await readDir(dirPath)
const target = baseDirsName.filter((dirName) => /^pages$|^package-\w+$/.test(dirName))
// 读取APP配置文件
let appConfigFileStr = await readFile(APP_CONFIG_PATH)
// app.config 分包配置
let subPackage = []
// 生成路由表文件
let routeTableFileStr = ''
// project.config 项目快捷入口配置
let entrance = []
let mainEntrance = []
for await (let i of target) {
if (i === 'pages') {
const dirsName = sortDirs(await readDir(path.resolve(dirPath, i)))
appConfigFileStr = handleMainPageConfig(dirsName, appConfigFileStr)
routeTableFileStr = handleMainRouteTable(dirsName, routeTableFileStr)
mainEntrance = handleMainProjectConfig(dirsName, [])
} else {
const dirsName = sortDirs(await readDir(path.resolve(dirPath, i, 'pages')))
subPackage = handleSubPageConfig(i, dirsName, subPackage)
routeTableFileStr = handleSubRouteTable(i, dirsName, routeTableFileStr)
entrance = handleSubProjectConfig(i, dirsName, entrance)
}
}
updateAppConfig(appConfigFileStr, subPackage)
updateRouteTable(routeTableFileStr)
updateProjectConfig(mainEntrance.concat(entrance))
}
// 主函数
main(path.resolve(BASE_PATH, 'src'))
工具脚本文件
const path = require('path')
const fs = require('fs')
// 当前路径是文件夹
function isDir(filePath) {
return new Promise((resolve, reject) => {
fs.stat(filePath, (err, stats) => {
if (err) {
reject(err)
} else {
resolve(stats.isDirectory())
}
})
})
}
// 读取当前文件夹目录
function readDir(filePath) {
return new Promise((resolve, reject) => {
fs.readdir(filePath, 'utf8', (err, dir) => {
if (err) {
reject(err)
} else {
resolve(dir)
}
})
})
}
// 读取文件
function readFile(filePath) {
return new Promise((resolve, reject) => {
fs.readFile(filePath, (err, dir) => {
if (err) {
reject(err)
} else {
resolve(dir.toString())
}
})
})
}
// 写文件
async function writeFile(filePath, fileStr) {
return new Promise((resolve, reject) => {
fs.writeFile(filePath, fileStr, (err, res) => {
if (err) return reject(err)
resolve(res)
})
})
}
// 文件夹扫描
async function fileScanner(filePath, fn) {
const dir = await isDir(filePath)
if (dir) {
const dirs = await readDir(filePath)
for await (const name of dirs) {
fileScanner(path.resolve(filePath, name), fn)
}
} else {
await fn(filePath)
}
}
module.exports = {
isDir,
readDir,
readFile,
writeFile,
fileScanner,
}