带你拆解 umi 约定式路由源码

917 阅读2分钟

约定式路由也叫文件路由,就是不需要手写配置,文件系统即路由,通过目录和文件及其命名分析出路由配置。

image.png

约定式路由的实现基于文件系统,所以我们需要根据文件夹生成路由配置文件。

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())

在启动项目的时候,同时执行该脚本,就会在项目文件发生变化的时候,自动生成路由配置文件。