1背景
在开发一款前端分析工具时,我们需要找出项目中的哪些文件被视为路由文件。
鉴于前端开发领域存在众多脚手架工具,本文将重点梳理其中一些代表性的脚手架,例如 next.js、umi。
2 什么是前端路由
路由是指确定数据或信息从一个地方到另一个地方的路径或方式。在计算机科学中,路由通常指的是网络中数据包从源地址到目标地址的传输路径。而在前端开发中,路由是指控制不同页面之间导航和展示的机制。它允许用户在应用程序中浏览不同的页面或视图,并根据需要切换和加载不同的内容。
前端路由的主要目的是根据 URL 的变化来决定显示哪个页面或组件。当用户在应用程序中点击链接或执行某些操作时,URL 会发生变化,前端路由会根据这些变化来匹配相应的路由规则,并加载对应的页面或组件。
所以我们的目的就是找到一个前端工程中的路由和它对应的组件文件(可能有多个,看不同的脚手架封装程度),并得到如下的数据结构。
ini
export type RouteType = {
/** 路由的地址 */
route: string;
/** 路由对应的组件绝对地址 */
components: string[];
/** 额外的信息 */
[x: string]: unknown;
};
3 配置式路由和约定式路由
现今前端脚手架中的路由设计,大致可以分为两种,配置式路由和约定式路由。
3.1 配置式路由
配置式路由通过在特定的文件中存储路由信息列表,例如下面的一个文件:
javascript
export default [
{
// 页面的名字
title: '用户列表',
// 路由URL
path: '/userlist',
// 对应的组件
component: () => import('pages/user'),
}
]
不仅存储了路由的地址、对应的组件,还可以存储标题信息。其优点就是批量管理易于维护、方便动态加载优化、存储额外信息,缺点就是需要编写对应的配置。
3.2 约定式路由
约定式路由,也叫文件路由,既通过文件的目录结构和层次生成路由。
例如下面的目录结构:
markdown
.
└── pages
├── index.tsx
└── user
└── index.tsx
会产出下面的两条路由:
/
/user
其优点就是不用编写配置文件,减少开发成本,但是也会带来一些隐形问题,例如不合理的组件存放位置导致组件被识别为了路由。
4 常用的前端脚手架梳理
4.1 next.js
Next.js 是一个基于 React 的轻量级、灵活且可扩展的前端框架,用于构建现代化的、高性能的 Web 应用程序。它提供了一种简单而强大的方式来开发服务器渲染(SSR)和静态生成(Static Generation)的 React 应用。
根据官方文档我们可以看成,next.js 使用了约定式路由,路由识别规则如下:
next.js 12版本以下
以下是一些常见的路由识别规则:
-
文件名映射为路由路径:在
pages
目录下的每个文件都会被映射为一个路由路径。例如,pages/about.js
文件将被映射为/about
路径。 -
文件夹映射为嵌套路由:如果在
pages
目录下创建一个文件夹,该文件夹的名称将被用作父级路由路径。例如,pages/blog
文件夹下的index.js
文件将被映射为/blog
路径,而pages/blog/post.js
文件将被映射为/blog/post
路径。 -
动态路由:Next.js 支持动态路由,可以通过使用方括号
[]
来定义动态部分。例如,pages/blog/[slug].js
文件将匹配/blog/any-value
形式的路径,并将slug
参数传递给页面组件。 -
错误页面:
- 404页面:
pages/404.js
- 500 页面:
pages/500.js
- 错误页面
pages/_error.js
- 404页面:
在 next.js 13 之后,app路由规则发生了一些变化,但也是约定式路由,本文不讨论。
所以我们只需要扫描目录即可,核心代码如下:
javascript
const pagePath = `pages`;
const list: RouteType[] = [];
const pageExtensions = ['.js'];
const ignoreList = ["_app.js", "_document.js", "_error.js"];
// 递归遍历 pages 目录
const getFileTree = async (p: string) => {
const fullPath = path.join(basePath, p);
const fileState = await gitFs.lstat(fullPath);
// 是文件
if (!fileState.isDirectory()) {
const isPage = pageExtensions.some((ext) => p.endsWith(ext));
const ignore = ignoreList.some((item) => p.endsWith(item));
if (!ignore && isPage) {
let route = p.replace("pages", "");
// 如果是 index结尾的文件,页面路径去掉index
pageExtensions.forEach((ext) => {
if (route.endsWith(ext)) {
route = route.replace(`${ext}`, "");
}
});
if (route.endsWith("index")) {
route = route.replace("index", "");
}
list.push({
title: route,
componentAbsolutePath: fullPath,
route,
});
}
return;
}
// 是文件夹
// api 文件夹不处理
if (p.endsWith("api")) return;
const fileList = await gitFs.readdir(fullPath);
for (let f of fileList) {
await getFileTree(path.join(p, f));
}
};
await getFileTree(pagePath);
4.2 umi
Umi,中文发音为「乌米」,是可扩展的企业级前端应用框架。Umi 以路由为基础,同时支持配置式路由和约定式路由,保证路由的功能完备,并以此进行功能扩展。然后配以生命周期完善的插件体系,覆盖从源码到构建产物的每个生命周期,支持各种功能扩展和业务需求。
umi 可以在 .umirc.ts
或config.ts
中配置路由。
javascript
export default {
routes: [
{ path: '/', component: 'index' },
{ path: '/user', component: 'user' },
],
}
根据UMI源码,Umi会通过require.extensions + esbuild直接解析,大致原理如下。
javascript
const { transformSync } = require("esbuild");
require.extensions['.ts'] = function (module, filename) {
const content = fs.readFileSync(filePath, 'utf-8')
const { outputText } = transformSync(content, {
sourcefile: filename,
loader: ext.slice(1),
// consistent with `tsconfig.base.json`
// https://github.com/umijs/umi-next/pull/729
target: 'es2019',
format: 'cjs',
logLevel: 'error',
}).code;
}
module._compile(outputText, filename)
}
const config = require('.umirc.ts').default