React Router V6.4 (FS Router篇)

1,402 阅读5分钟

主流的 fs 目录结构

fs 路由 目录结构 Next.js 上最为流行,越来越多的 JavaScript Meta 框架支持 类 fs 路由模式, 并且逐渐抛弃了 src 源代码目录结构模式,使用 /app 模式, 也就是说一个 Meta 框架,以库模式定义目录结构已经不合适现有的工程,因为现有的 Meta 框架,前后端正在快速的打通,包括 类型(TypeScript 类型)。

目标

  • 分析 Remix 文件系统基本功能,关于 Remix 的路由可以参考这篇 文章
  • vite-plugin-remix-router vite 插件的实现看 vite 中如何实现 remix-router 类似的功能。

Remix 项目目录结构

这里以 Remix 为例(Next.js 目录结构 /app 迁移在测试当中),重点关注路由 routes/ 部分(并不是其他的部分不重要)

├── README.md
├── app
│   ├── entry.client.tsx
│   ├── entry.server.tsx
│   ├── root.tsx
│   └── routes
│       └── index.tsx
├── package.json
├── public
│   └── favicon.ico
├── remix.config.js
├── remix.env.d.ts
└── tsconfig.json

在 routes 中添加文件就是一个路由 about.tsx,about 自动称为一个路由,不在需要手东配置,本质: 约定待 > 配置

基本路由与地址对应关系

url匹配的路由组件
/app/routes/index.tsx
/aboutapp/routes/about.tsx

动态路由

动态路由中使用 $var 使用变量表示动态路由路径

└── routes
    ├── about.tsx
    ├── article
    │   ├── $id.tsx
    │   └── index.tsx
    └── index.tsx

组件与路地址对应关系

url匹配的路由组件
/app/routes/index.tsx
/aboutapp/routes/about.tsx
/article/indexapp/routes/article.tsx
/article/123app/routes/$id.tsx

布局组件

路由组件使用 双下划线定义 __layout.tsx 组件,布局组件不占据路由路径的位置。

.
├── __layout
│   └── dashboard.tsx
├── __layout.tsx
├── about.tsx
├── article
│   ├── $id.tsx
│   └── index.tsx
└── index.tsx

使用 . 分割路由地址

处理使用文件系统之外,我们可以使用 . 来替代文件夹作用

.
├── __layout
│   └── dashboard.tsx
├── __layout.tsx
├── about.tsx
├── article
│   ├── $id.tsx
│   └── index.tsx
├── blog.authors.tsx
└── index.tsx

小结

  • 使用文件夹代理路由,注意 index 文件表示当前文件夹的路由(在布局文件夹下的index不生效)
  • 动态路由使用 $var开始变量
  • fs 路由中的布局解决方案
  • 使用 . 进行分割的文件路由方案

实现篇分析(以 vite-plugin-remix-router 为例)

本质使用 rollup 的编译能力,添加虚拟模块,在虚拟模块中添加 router 相关的组件。技能上从能控制 React 组件到能够控制 React 组件对应的字符串能力。

5b8e34631ee74c3ce8e47b55026f8830.JPG

使用方法

pnpm install react-router-dom -S
pnpm install vite-plugin-remix-router -D
  • 下载配置 vite 插件
import { defineConfig } from 'vite'
import RemixRouter from 'vite-plugin-remix-router'

export default defineConfig({
  plugins: [RemixRouter()],
})
  • 配置 routes 文件夹
.
├── $.tsx
├── __auth
│   └── login.tsx
├── __panel
│   └── users
│       ├── $user
│       │   ├── index.tsx
│       │   ├── profile.tsx
│       │   └── settings.tsx
│       ├── $user.tsx
│       └── index.tsx
├── __panel.tsx
├── about.tsx
├── ignored.ts
└── index.tsx
  • 导入并生成
import { createBrowserRouter } from 'react-router-dom'
import { routes } from 'virtual:routes'

export const router = createBrowserRouter(routes)
  • 在主函数中渲染 router
import { router } from './router'
import { Suspense } from 'react'
import { MainLayout } from './components/MainLayout'
import { RouterProvider } from 'react-router-dom'

function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <MainLayout>
        <RouterProvider router={router} />
      </MainLayout>
    </Suspense>
  )
}

export default App

使用虚拟模块

export const VIRTUAL_MODULE_ID = 'virtual:routes'

export const RESOLVED_VIRTUAL_MODULE_ID = `\0${VIRTUAL_MODULE_ID}`

定义 虚拟模块 id被决策的虚拟模块 id, 被决策虚拟模块 id 式 \0 开头的,这式 rollup 插件中常见的虚拟模块处理方法。

配置项目

  • 可选项, 其实就是三个属性(不多)
export interface Options {
  routesDirectory: string
  extensions: string[]
}
export type UserOptions = Partial<Options>
export interface ResolvedOptions extends Options {
  root: string
}
  • 定义常量
const defaultOptions: Options = {
  routesDirectory: 'src/routes',
  extensions: ['tsx', 'jsx'],
}

let resolvedOptions: ResolvedOptions | null = null
  • 操作方法,操作上面的类型和
export function resolveOptions(root: string, userOptions?: UserOptions) {
  return {
    root: root ?? normalizePath(process.cwd()),
    ...defaultOptions,
    ...userOptions,
  }
}

export function setOptions(options: ResolvedOptions) {
  resolvedOptions = options
}

export function getOptions() {
  if (resolvedOptions === null) {
    throw new Error('Something went wrong. Unable to resolve "UserOptions".')
  }

  return resolvedOptions
}

涉及的 vite 插件的生命周期

pre 字段表示插件调用顺序, 如果不熟悉 vite 插件的写法,推荐这篇文章 插件api, 其次可能要了解 rollup 插件,因为 vite 底层是模拟的 rollup 的插件功能来实现。

  • configResolved 解析 Vite 配置后调用,读取 root 配置,开始构建 routeTree
configResolved({ root }) {
  setOptions(resolveOptions(root, userOptions))
  routeTree = buildRouteTree()
}
  • configureServer 监听文件(路由文件)的增删改查,并且重新创建路由
configureServer(server) {
  server.watcher.on('unlink', (filePath) => {
    if (!isRouteFile(filePath)) {
      return
    }

    routeTree = buildRouteTree()
    reloadServer(server)
  })

  server.watcher.on('add', (filePath) => {
    if (!isRouteFile(filePath)) {
      return
    }

    routeTree = buildRouteTree()
    reloadServer(server)
  })

  server.watcher.on('change', (filePath) => {
    if (!isRouteFile(filePath)) {
      return
    }

    reloadServer(server)
  })
},

resolveId(id) {
  if (id === VIRTUAL_MODULE_ID) {
    return RESOLVED_VIRTUAL_MODULE_ID
  }

  return null
},
  • resolveId 只处理 RESOLVED_VIRTUAL_MODULE_ID
resolveId(id) {
  if (id === VIRTUAL_MODULE_ID) {
    return RESOLVED_VIRTUAL_MODULE_ID
  }

  return null
}
  • load 生成虚拟的 React 组件代码,包含 fs-remix-router
load(id) {
  if (id === RESOLVED_VIRTUAL_MODULE_ID) {
    return generateRoutesModule(routeTree)
  }

  return null
}

面向对象抽象出路径节点

这是面向对象的基本抽象能力:将需要面对的问题,抽象为一个个属性去处理,组合成一个对象,然后封装一些方法区处理这些对象,说着着根 Vue 2 好像啊,React 其实还是偏向函数式,

export class RouteNode {
  name: string
  path: string
  children: Array<RouteNode> = []
  isDirectory?: boolean
  layoutPath?: string
  constructor(filePath: string) {
    this.name = path.parse(filePath).name
    this.path = filePath
  }
}

使用递归函数根据文件路径创建节点

function createNode(filePath: string) {
  const node = new RouteNode(filePath)

  if (isDirectory(toAbsolutePath(filePath))) {
    node.isDirectory = true
    node.layoutPath = getLayoutPath(filePath)

    const children = resolveChildren(toAbsolutePath(filePath))
    node.children = children.map((child) => createNode(`${filePath}/${child}`))
  }

  return node
}

获取 layout 路径

function getLayoutPath(directoryPath: string) {
  return getOptions()
    .extensions.map((extension) => `${directoryPath}.${extension}`)
    .find((filePath) => fs.existsSync(toAbsolutePath(filePath)))
}

构建路由树

export function buildRouteTree(): RouteNode {
  const root = createNode(getOptions().routesDirectory)
  root.isDirectory = true
  root.name = '/'

  return root
}

生成路由模块

2a62270cfa860c8c8b95326b6214954f.JPG

export function generateRoutesModule(rootNode: RouteNode) {
  imports = []
  const routes = createRouteObject(rootNode)

  const code: Array<string> = []
  code.push("import React from 'react';")
  code.push(...imports)
  code.push('')

  const routesString = JSON.stringify(routes, null, 2)
    .replace(/\\"/g, '"')
    .replace(/("::|::")/g, '')

  code.push(`export const routes = [${routesString}]\n`)

  return code.join('\n')
}

generateRoutesModule 函数是生成 load 钩子中需要 code 重要函数

使用根节点创建 routes

const routes = createRouteObject(rootNode)

创建路由节点,分为两种情况:

  • 一种是布局: createLayoutRoute
  • 一种是页面: createPageRoute
function createRouteObject(node: RouteNode) {
  if (node.isDirectory) {
    return createLayoutRoute(node)
  }

  return createPageRoute(node)
}

创建布局路由

function createLayoutRoute(node: RouteNode): RouteObject {
  return {
    element: node.layoutPath && createRouteElement(node.layoutPath),
    path: node.name.startsWith('__')
      ? undefined
      : normalizeFilenameToRoute(node.name),
    children: node.children.map((child) => createRouteObject(child)),
  }
}

创建布局中重要的路由属性 children 的处理方式是调用路由 createRouteObject,递归的思想。path 处理以 __ 开头字符串。

创建页面路由

function createPageRoute(node: RouteNode): RouteObject {
  const code = fs.readFileSync(toAbsolutePath(node.path), 'utf8')

  const path =
    node.name === 'index'
      ? { index: true }
      : { path: normalizeFilenameToRoute(node.name) }

  return {
    ...path,
    loader: resolveLoader(node.path, code) as LoaderFunction | undefined,
    action: resolveAction(node.path, code) as ActionFunction | undefined,
    errorElement: resolveErrorElement(node.path, code),
    element: createRouteElement(node.path),
  }
}

创建页面路由需要注意的点:

  • code 字符串
  • index 路由(带有 index 属性的 Route)
  • 加载 loader 函数
  • 加载 action 函数

下面是加载 loader 和 action 的方法:

function resolveLoader(filePath: string, code: string) {
  if (hasLoader(code)) {
    const importName = createImportName(filePath, 'LOADER')
    imports.push(`import { loader as ${importName} } from '/${filePath}';`)
    return `::${importName}::`
  }
  return undefined
}

function resolveAction(filePath: string, code: string) {
  if (hasAction(code)) {
    const importName = createImportName(filePath, 'ACTION')
    imports.push(`import { action as ${importName} } from '/${filePath}';`)
    return `::${importName}::`
  }

  return undefined
}

这里使用 ::${importName}:: 字符串是方便处,正则匹配。创建字符串 React 元素的时候也使用同样的操作:

function createRouteElement(filePath: string) {
  return `::React.createElement(React.lazy(() => import("/${filePath}")))::`
}

当然编程中怎么少的了错误的处理:

function resolveErrorElement(filePath: string, code: string) {
  if (hasErrorElement(code)) {
    const importName = createImportName(filePath, 'ERROR_ELEMENT').toUpperCase()

    imports.push(
      `import { ErrorElement as ${importName} } from '/${filePath}';`,
    )

    return `::React.createElement(${importName})::`
  }

  if (hasErrorBoundary(code)) {
    const importName = createImportName(filePath, 'ERROR_ELEMENT').toUpperCase()

    imports.push(
      `import { ErrorBoundary as ${importName} } from '/${filePath}';`,
    )

    return `::React.createElement(${importName})::`
  }

  return undefined
}

小结

  • vite 插件中插入虚拟 React 组件 在 load 钩子函数中开始解析
  • 使用面向对象的方:将 path, name, children,文件名,文件夹名... 抽象为一个个属性和方法
  • 组件编译成了字符串,添加到 vite 编译好的代码中
  • 使用时,采用虚拟模块引入。
  • configureServer 钩子函数监听 routes 文件夹路径变化,重新读取文件,创建新的路由。

文章推荐

其他参考

= # File-based routing with React Location — Nested layouts

tip

正在参加投票活动,如果本文章真的能帮助到您,希望发财的小手👇👇点一点下面的按钮,投一票给作者,是对作者最大鼓励,也可微信搜索公众号 进二开物 更多内容在更新中, 其你的关注...