主流的 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 |
/about | app/routes/about.tsx |
动态路由
动态路由中使用 $var
使用变量表示动态路由路径
└── routes
├── about.tsx
├── article
│ ├── $id.tsx
│ └── index.tsx
└── index.tsx
组件与路地址对应关系
url | 匹配的路由组件 |
---|---|
/ | app/routes/index.tsx |
/about | app/routes/about.tsx |
/article/index | app/routes/article.tsx |
/article/123 | app/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 组件对应的字符串能力。
使用方法
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
}
生成路由模块
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 文件夹路径变化,重新读取文件,创建新的路由。
文章推荐
- React Router V6.4 (React Router 组件-钩子函数篇)
- React Router V6.4 (router 测试篇)
- React Router V6.4 (Router 对象篇)
- React Router V6.4 (history 对象篇)
- React Router V6.4 基础知识(地基篇)
其他参考
= # File-based routing with React Location — Nested layouts
- # File-based routing with React Router — Pre-loading
- # File-based routing with React Router — Code-splitting
- # File-based routing with React Router — Upgrading to v6
- # routes-files remix 的文件系统路由。
tip
正在参加投票活动,如果本文章真的能帮助到您,希望发财的小手👇👇点一点下面的按钮,投一票给作者,是对作者最大鼓励,也可微信搜索公众号 进二开物
更多内容在更新中, 其你的关注...