我们都知道,小程序开发一个页面,首先要在 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,
}