约定式路由也叫文件路由,就是不需要手写配置,文件系统即路由,通过目录和文件及其命名分析出路由配置。
约定式路由的实现基于文件系统,所以我们需要根据文件夹生成路由配置文件。
export function getConventionRoutes(opts: {
base: string;
prefix?: string;
exclude?: RegExp[];
}) {
const files: { [routeId: string]: string } = {};
if (!(existsSync(opts.base) && statSync(opts.base).isDirectory())) {
return {} as {[key: string]: IRoute};
}
visitFiles({
dir: opts.base,
visitor: (file) => {
const routeId = createRouteId(file);
if (isRouteModuleFile({ file, exclude: opts.exclude })) {
files[routeId] = file;
}
},
});
const routeIds = Object.keys(files).sort(byLongestFirst);
function defineNestedRoutes(defineRoute: any, parentId?: string) {
const childRouteIds = routeIds.filter(
(id) => findParentRouteId(routeIds, id) === parentId,
);
for (let routeId of childRouteIds) {
let routePath = createRoutePath(
parentId ? routeId.slice(parentId.length + 1) : routeId,
);
defineRoute({
path: routePath,
file: `${opts.prefix || ''}${files[routeId]}`,
children() {
defineNestedRoutes(defineRoute, routeId);
},
});
}
}
return defineRoutes(defineNestedRoutes);
}
上面的函数会根据传入的base也就是源码目录,生成一个文件对象。
其中 visitFiles 是遍历文件目录的方法:
根据传入的路径,对文件夹内所有的文件都调用传入的 visitor 方法。
import { existsSync, readFileSync, readdirSync, statSync, lstatSync } from 'fs';
function visitFiles(opts: {
dir: string;
visitor: (file: string) => void;
baseDir?: string;
}): void {
opts.baseDir = opts.baseDir || opts.dir;
for (let filename of readdirSync(opts.dir)) {
let file = resolve(opts.dir, filename);
let stat = lstatSync(file);
if (stat.isDirectory()) {
visitFiles({ ...opts, dir: file });
} else if (
stat.isFile() &&
['.tsx', '.ts', '.js', '.jsx', '.md', '.mdx', '.vue'].includes(
extname(file)
)
) {
opts.visitor(relative(opts.baseDir, file));
}
}
}
在传入的visitor中,将每个文件的文件名作为key,对应的文件路径作为值,记录到files对象中。
其中 isRouteModuleFile 函数用来判断是否是一个模块文件,也就是是否要被记录到路由配置中。
import { parse as babelParse } from '@babel/parser'
import { join, resolve, relative, extname } from 'path';
import traverse from '@babel/traverse';
const routeModuleExts = ['.js', '.jsx', '.ts', '.tsx', '.md', '.mdx', '.vue'];
export function isRouteModuleFile(opts: { file: string; exclude?: RegExp[] }) {
for (const excludeRegExp of opts.exclude || []) {
if (
opts.file &&
excludeRegExp instanceof RegExp &&
excludeRegExp.test(opts.file)
) {
return false;
}
}
const content = readFileSync(resolve('src/pages/',opts.file), 'utf-8')
return routeModuleExts.includes(extname(opts.file)) && isReactComponent(content)
}
export function isReactComponent(code: string) {
const ast = babelParse(code, {
sourceType: 'module',
plugins: ['typescript', 'jsx', 'classProperties']
});
let hasJSXElement = false;
traverse(ast as any, {
JSXElement(path) {
hasJSXElement = true;
path.stop();
},
});
return hasJSXElement;
}
后续的 defineNestedRoutes 函数用于定义嵌套路由的数据结构。
现在我们已经可以根据 源码内容 获得一个需要生成配置路由文件的对象了,只需要将文件路径 写入路由配置文件中即可:
export const generatorGetRouterCode = (fileList: IRoute[]) => {
return `export function getRoutes() {
const routes = [
{
"path": "/",
"component": { loader: () => import(/* webpackChunkName: 'layouts__index' */'@/layouts')},
"routes": [${fileList.map(file => `{
"path": "/${file.path}",
"component": { loader: () => import('@/pages${file.absPath}') }
}`).join(',')}]
}
];
return routes;
}
`
writeFileSync(resolve(pagePath, '../.router/getRouter.ts'), generatorGetRouterCode(routerList))
}
上面的代码会在 pagePath 的路径下面生成 .router/getRouter.ts 的文件,文件内容如下:
export function getRoutes() {
const routes = [
{
"path": "/",
"component": { loader: () => import(/* webpackChunkName: 'layouts__index' */'@/layouts')},
"routes": [{
"path": "/manifest/create",
"component": { loader: () => import('@/pages/manifest/create') }
},{
"path": "/manifest/list",
"component": { loader: () => import('@/pages/manifest/list') }
},{
"path": "/trade/list",
"component": { loader: () => import('@/pages/trade/list') }
},{
"path": "/signup",
"component": { loader: () => import('@/pages/signup') }
},{
"path": "/login",
"component": { loader: () => import('@/pages/login') }
}]
}
];
return routes;
}
现在只需要将该内容引用到Router组件即可, 下面的代码还处理了动态路由引用的场景:
import React, { Component } from 'react'
import { Routes, Route } from "react-router-dom";
import { getRoutes } from './getRouter';
const asyncComponent = (importComponent) => {
class InnerComponent extends Component<any, any> {
constructor(porps) {
super(porps)
this.state = {
component: null
}
}
componentDidMount() {
importComponent()
.then(cmp => {
this.setState({ component: cmp.default }) //.default 是模块有default输出接口
})
}
render() {
const C = this.state.component;
return C ? <C {...this.props} /> : null;
}
}
return <InnerComponent />
}
const getRouterList = (routers: ReturnType<typeof getRoutes>) => {
const _concat = (_routers: any[]) => {
return _routers?.map(router =>
<Route
key={router.path}
path={router.path}
element={asyncComponent(router.component.loader)}
>
{_concat(router?.routes)}
</Route>
)
}
return _concat(routers)
}
const Router = () => {
const routers = getRoutes()
const routerList = getRouterList(routers)
return (
<Routes>
{routerList}
</Routes>
);
}
export default Router
现在,我们可以完成了约定式路由的实现。但是在编辑代码的时候,新增了文件夹,如何自动修改路由配置文件呢?这就要用到 chokidar 模块了。
import chokidar from 'chokidar'
import { throttle } from 'lodash'
import { existsSync, statSync } from 'fs';
import { join } from 'path';
interface Opts {
path?: string
singular?: string
callback: () => void
}
function isDirectoryAndExist(path: string) {
return existsSync(path) && statSync(path).isDirectory();
}
export class Watcher {
callback: () => void
watcher: chokidar.FSWatcher
constructor (config: Opts) {
this.callback = config.callback
let cwd = process.cwd()
let absSrcPath = cwd;
if (isDirectoryAndExist(join(cwd, 'src'))) {
absSrcPath = join(cwd, 'src');
}
const absPagesPath = config.singular
? join(absSrcPath, 'page')
: join(absSrcPath, 'pages');
this.createWatcher(absPagesPath)
}
unwatch () {
this.watcher.close()
}
createWatcher (path: string) {
const watcher = chokidar.watch(path, {
// ignore .dot_files and _mock.js
ignored: /(^|[\/\\])(_mock.js$|\..)/,
ignoreInitial: true,
});
watcher.on(
'all',
throttle(async (event, path) => {
this.callback()
}, 100),
)
return watcher
}
}
代码使用 chokidar 模块监听文件变化,监测到文件变化就执行 callback 函数。
const generator = () => {
const conventionRouter = getConventionRoutes({ base: pagePath })
const routerList = Object.values(conventionRouter)
if(!existsSync(resolve(pagePath, '../.router/getRouter.ts'))){
mkdirSync(resolve(pagePath, '../.router'))
}
writeFileSync(resolve(pagePath, '../.router/getRouter.ts'), generatorGetRouterCode(routerList))
}
const watcher = new Watcher({
callback: generator
})
process.on('SIGINT', () => watcher.unwatch())
process.on('SIGTERM', () => watcher.unwatch())
在启动项目的时候,同时执行该脚本,就会在项目文件发生变化的时候,自动生成路由配置文件。