mirror of
https://github.com/WeBankFinTech/fes.js.git
synced 2025-04-05 19:41:57 +08:00
357 lines
12 KiB
JavaScript
357 lines
12 KiB
JavaScript
import { readdirSync, statSync, readFileSync } from 'fs';
|
||
import { join, extname, posix, basename } from 'path';
|
||
import { lodash, parser, generator } from '@fesjs/utils';
|
||
import { parse } from '@vue/compiler-sfc';
|
||
import { Logger } from '@fesjs/compiler';
|
||
import { runtimePath } from '../../../utils/constants';
|
||
|
||
const logger = new Logger('fes:router');
|
||
|
||
// pages
|
||
// ├── index.vue # 根路由页面 路径 /
|
||
// ├── *.vue # 模糊匹配 路径 *
|
||
// ├── a.vue # 路径 /a
|
||
// ├── b
|
||
// │ ├── index.vue # 路径 /b
|
||
// │ ├── @id.vue # 动态路由 /b/:id
|
||
// │ └── c.vue # 路径 /b/c
|
||
// └── layout.vue # 根路由下所有page共用的外层
|
||
|
||
const isProcessFile = function (path) {
|
||
const ext = extname(path);
|
||
return statSync(path).isFile() && ['.vue', '.jsx', '.tsx'].includes(ext);
|
||
};
|
||
|
||
const isProcessDirectory = function (path, item) {
|
||
const component = join(path, item);
|
||
return statSync(component).isDirectory() && !['components'].includes(item);
|
||
};
|
||
|
||
const checkHasLayout = function (path) {
|
||
const dirList = readdirSync(path);
|
||
return dirList.some((item) => {
|
||
if (!isProcessFile(join(path, item))) {
|
||
return false;
|
||
}
|
||
const ext = extname(item);
|
||
const fileName = basename(item, ext);
|
||
return fileName === 'layout';
|
||
});
|
||
};
|
||
|
||
const getRouteName = function (parentRoutePath, fileName) {
|
||
const routeName = posix.join(parentRoutePath, fileName);
|
||
return routeName.slice(1).replace(/\//g, '_').replace(/@/g, '_').replace(/\*/g, 'FUZZYMATCH');
|
||
};
|
||
|
||
const getPagePathPrefix = (config) => `@/${config.singular ? 'page' : 'pages'}`;
|
||
|
||
const getComponentPath = function (parentRoutePath, fileName, config) {
|
||
return posix.join(`${getPagePathPrefix(config)}/`, parentRoutePath, fileName);
|
||
};
|
||
|
||
const getRoutePath = function (parentRoutePath, fileName) {
|
||
// /index.vue -> /
|
||
if (fileName === 'index') {
|
||
fileName = '';
|
||
}
|
||
// /@id.vue -> /:id
|
||
if (fileName.startsWith('@')) {
|
||
fileName = fileName.replace(/@/, ':');
|
||
}
|
||
// /*.vue -> :pathMatch(.*)
|
||
if (fileName.includes('*')) {
|
||
fileName = fileName.replace('*', ':pathMatch(.*)');
|
||
}
|
||
return posix.join(parentRoutePath, fileName);
|
||
};
|
||
|
||
function getRouteMeta(content) {
|
||
const ast = parser.parse(content, {
|
||
sourceType: 'module',
|
||
plugins: ['jsx', 'typescript'],
|
||
});
|
||
const defineRouteExpression = ast.program.body.filter(
|
||
(expression) =>
|
||
expression.type === 'ExpressionStatement' &&
|
||
expression.expression.type === 'CallExpression' &&
|
||
expression.expression.callee.name === 'defineRouteMeta',
|
||
)[0];
|
||
if (defineRouteExpression) {
|
||
const argument = generator(defineRouteExpression.expression.arguments[0]);
|
||
return JSON.parse(argument.code.replace(/'/g, '"').replace(/(\S+):/g, (global, m1) => `"${m1}":`));
|
||
}
|
||
return null;
|
||
}
|
||
|
||
let cacheGenRoutes = {};
|
||
|
||
// TODO 约定 layout 目录作为布局文件夹,
|
||
const genRoutes = function (parentRoutes, path, parentRoutePath, config) {
|
||
const dirList = readdirSync(path);
|
||
const hasLayout = checkHasLayout(path);
|
||
const layoutRoute = {
|
||
children: [],
|
||
};
|
||
if (hasLayout) {
|
||
layoutRoute.path = parentRoutePath;
|
||
parentRoutes.push(layoutRoute);
|
||
}
|
||
dirList.forEach((item) => {
|
||
// 文件或者目录的绝对路径
|
||
const component = join(path, item);
|
||
if (isProcessFile(component)) {
|
||
const ext = extname(item);
|
||
const fileName = basename(item, ext);
|
||
// 路由的path
|
||
const routePath = getRoutePath(parentRoutePath, fileName);
|
||
if (cacheGenRoutes[routePath]) {
|
||
logger.warn(
|
||
`[WARNING]: The file path: ${routePath}(.jsx/.tsx/.vue) conflict in router,will only use ${routePath}.tsx or ${routePath}.jsx,please remove one of.`,
|
||
);
|
||
return;
|
||
}
|
||
cacheGenRoutes[routePath] = true;
|
||
|
||
// 路由名称
|
||
const routeName = getRouteName(parentRoutePath, fileName);
|
||
const componentPath = getComponentPath(parentRoutePath, ext === '.vue' ? `${fileName}${ext}` : fileName, config);
|
||
|
||
let content = readFileSync(component, 'utf-8');
|
||
let routeMeta = {};
|
||
if (ext === '.vue') {
|
||
const { descriptor } = parse(content);
|
||
const routeMetaBlock = descriptor.customBlocks.find((b) => b.type === 'config');
|
||
routeMeta = routeMetaBlock?.content ? JSON.parse(routeMetaBlock.content) : {};
|
||
if (descriptor.script) {
|
||
content = descriptor.script.content;
|
||
routeMeta = getRouteMeta(content) || routeMeta;
|
||
}
|
||
}
|
||
if (ext === '.jsx' || ext === '.tsx') {
|
||
routeMeta = getRouteMeta(content) || {};
|
||
}
|
||
|
||
const routeConfig = {
|
||
path: routePath,
|
||
component: componentPath,
|
||
name: routeMeta.name || routeName,
|
||
meta: routeMeta,
|
||
};
|
||
if (hasLayout) {
|
||
if (fileName === 'layout') {
|
||
layoutRoute.component = componentPath;
|
||
} else {
|
||
layoutRoute.children.push(routeConfig);
|
||
}
|
||
} else {
|
||
parentRoutes.push(routeConfig);
|
||
}
|
||
}
|
||
});
|
||
|
||
dirList.forEach((item) => {
|
||
if (isProcessDirectory(path, item)) {
|
||
// 文件或者目录的绝对路径
|
||
const component = join(path, item);
|
||
const nextParentRouteUrl = posix.join(parentRoutePath, item);
|
||
if (hasLayout) {
|
||
genRoutes(layoutRoute.children, component, nextParentRouteUrl, config);
|
||
} else {
|
||
genRoutes(parentRoutes, component, nextParentRouteUrl, config);
|
||
}
|
||
}
|
||
});
|
||
};
|
||
|
||
/**
|
||
* 智能路由
|
||
* 1、路由的路径是多个“/”组成的字符串,使用“/”分割后得到不同的子项
|
||
* 2、计算子项个数,用个数乘以4,计入得分
|
||
* 3、判断子项是否是静态的,即不包含“:”、“*”等特殊字符串,若是计入3分。
|
||
* 4、判断子项是否是动态的,即包含“:”特殊字符,若是计入2分。
|
||
* 5、判断子项是否是模糊匹配,即包含“*”特殊字符,若是扣除1分。
|
||
* 6、判断子项是否是根端,即只是“/”,若是计入1分。
|
||
|
||
* @param {*} routes
|
||
*/
|
||
const rank = function (routes) {
|
||
routes.forEach((item) => {
|
||
const path = item.path;
|
||
let arr = path.split('/');
|
||
if (arr[0] === '') {
|
||
arr = arr.slice(1);
|
||
}
|
||
let count = 0;
|
||
arr.forEach((sonPath) => {
|
||
count += 4;
|
||
if (sonPath.indexOf(':') !== -1 && sonPath.indexOf(':pathMatch(.*)') === -1) {
|
||
count += 2;
|
||
} else if (sonPath.indexOf(':pathMatch(.*)') !== -1) {
|
||
count -= 1;
|
||
} else if (sonPath === '') {
|
||
count += 1;
|
||
} else {
|
||
count += 3;
|
||
}
|
||
});
|
||
item.count = count;
|
||
if (item.children && item.children.length) {
|
||
rank(item.children);
|
||
}
|
||
});
|
||
routes.sort((a, b) => b.count - a.count);
|
||
};
|
||
|
||
const getRoutes = function ({ config, absPagesPath }) {
|
||
// 用户配置了routes则使用用户配置的
|
||
const configRoutes = config.router.routes;
|
||
if (configRoutes && configRoutes.length > 0) return configRoutes;
|
||
|
||
const routes = [];
|
||
cacheGenRoutes = {};
|
||
genRoutes(routes, absPagesPath, '/', config);
|
||
rank(routes);
|
||
return routes;
|
||
};
|
||
|
||
function genComponentName(component, config) {
|
||
return lodash.camelCase(component.replace(getPagePathPrefix(config), '').replace('.vue', ''));
|
||
}
|
||
|
||
function isFunctionComponent(component) {
|
||
// eslint-disable-next-line
|
||
return (
|
||
/^\((.+)?\)(\s+)?=>/.test(component)
|
||
|| /^function([^(]+)?\(([^)]+)?\)([^{]+)?{/.test(component)
|
||
);
|
||
}
|
||
|
||
const getRoutesJSON = function ({ routes, config }) {
|
||
// 因为要往 routes 里加无用的信息,所以必须 deep clone 一下,避免污染
|
||
const clonedRoutes = lodash.cloneDeep(routes);
|
||
|
||
function replacer(key, value) {
|
||
switch (key) {
|
||
case 'component':
|
||
if (isFunctionComponent(value)) return value;
|
||
if (config.dynamicImport) {
|
||
// TODO 针对目录进行 chunk 划分,import(/* webpackChunkName: "group-user" */ './UserDetails.vue')
|
||
return `() => import('${value}')`;
|
||
}
|
||
return genComponentName(value, config);
|
||
default:
|
||
return value;
|
||
}
|
||
}
|
||
|
||
return JSON.stringify(clonedRoutes, replacer, 2)
|
||
.replace(/"component": ("(.+?)")/g, (global, m1, m2) => `"component": ${m2.replace(/\^/g, '"')}`)
|
||
.replace(/\\r\\n/g, '\r\n')
|
||
.replace(/\\n/g, '\r\n');
|
||
};
|
||
|
||
function genComponentImportExpression(routes, config) {
|
||
if (config.dynamicImport) {
|
||
return [];
|
||
}
|
||
|
||
const result = [];
|
||
for (const routeConfig of routes) {
|
||
if (routeConfig.children) {
|
||
result.push(...genComponentImportExpression(routeConfig.children, config));
|
||
}
|
||
|
||
if (routeConfig.component && !isFunctionComponent(routeConfig.component)) {
|
||
result.push(`import ${genComponentName(routeConfig.component, config)} from '${routeConfig.component}';`);
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
export default function (api) {
|
||
api.describe({
|
||
key: 'router',
|
||
config: {
|
||
schema(joi) {
|
||
return joi.object({
|
||
routes: joi.array(),
|
||
mode: joi.string(),
|
||
});
|
||
},
|
||
default: {
|
||
mode: 'hash',
|
||
},
|
||
},
|
||
});
|
||
|
||
api.registerMethod({
|
||
name: 'getRoutes',
|
||
async fn() {
|
||
return api.applyPlugins({
|
||
key: 'modifyRoutes',
|
||
type: api.ApplyPluginsType.modify,
|
||
initialValue: getRoutes({
|
||
config: api.config,
|
||
absPagesPath: api.paths.absPagesPath,
|
||
}),
|
||
});
|
||
},
|
||
});
|
||
|
||
api.registerMethod({
|
||
name: 'getRoutesJSON',
|
||
async fn(routes) {
|
||
return getRoutesJSON({ routes: await (routes || api.getRoutes()), config: api.config });
|
||
},
|
||
});
|
||
|
||
const {
|
||
utils: { Mustache },
|
||
} = api;
|
||
|
||
const namespace = 'core/routes';
|
||
|
||
const absCoreFilePath = join(namespace, 'routes.js');
|
||
|
||
const absRuntimeFilePath = join(namespace, 'runtime.js');
|
||
|
||
const historyType = {
|
||
history: 'createWebHistory',
|
||
hash: 'createWebHashHistory',
|
||
memory: 'createMemoryHistory',
|
||
};
|
||
|
||
api.onGenerateFiles(async () => {
|
||
const routesTpl = readFileSync(join(__dirname, 'template/routes.tpl'), 'utf-8');
|
||
const routes = await api.getRoutes();
|
||
|
||
api.writeTmpFile({
|
||
path: absCoreFilePath,
|
||
content: Mustache.render(routesTpl, {
|
||
runtimePath,
|
||
COMPONENTS_IMPORT: genComponentImportExpression(routes, api.config).join('\n'),
|
||
routes: await api.getRoutesJSON(),
|
||
config: api.config,
|
||
routerBase: api.config.base,
|
||
CREATE_HISTORY: historyType[api.config.router.mode] || 'createWebHashHistory',
|
||
}),
|
||
});
|
||
|
||
api.writeTmpFile({
|
||
path: absRuntimeFilePath,
|
||
content: readFileSync(join(__dirname, 'template/runtime.tpl'), 'utf-8'),
|
||
});
|
||
});
|
||
|
||
api.addCoreExports(() => [
|
||
{
|
||
specifiers: ['getRoutes', 'getRouter', 'getHistory', 'destroyRouter'],
|
||
source: absCoreFilePath,
|
||
},
|
||
]);
|
||
|
||
api.addRuntimePlugin(() => `@@/${absRuntimeFilePath}`);
|
||
}
|