umi阅读分为5个部分,分别是:
- 启动过程:从umi dev到如何调用umi的核心service
- 实例化过程:Service的实例化过程,以及启动dev之后的编译启动
- 渲染过程:研究umi如何将模块挂接渲染的
- routes渲染过程:从渲染入口研究umi的routes渲染机制
- 插件机制:重点研究umi的插件机
以下代码以umi的3.5.20版本为例,主要内容以源码+个人解读为主
routes渲染过程
内容:这一部分主要研究两种路由方式的实现内容,umi提供两种路由方式,一种通过配置config获取,一种是约定式路由。
路由
前面研究发现实现路由的代码是在packages/renderer-react/src/renderClient/renderClient.tsx
中,
在IRouterComponentProps中返回了一段这样的代码
<Router history={history}>{renderRoutes(renderRoutesProps)}</Router>
这里使用Router进行路由控制,react-router v5路由控制的主要写法,renderRoutes的流程也是实现以下格式路由代码。研究过程发现,Router、Switch、Route都被umi改造过,但实现的功能和原先组件基本一样,所以这里不深究这些。
<Router>
<div>
<Switch>
<Route path="/about">
<About />
</Route>
<Route path="/users">
<Users />
</Route>
<Route path="/">
<Home />
</Route>
</Switch>
</div>
</Router>
我们来看看renderRoutes的操作
export default function renderRoutes(opts: IOpts, switchProps = {}) {
return opts.routes ? (
<Switch {...switchProps}>
{opts.routes.map((route, index) =>
getRouteElement({
route,
index,
opts: {
...opts,
rootRoutes: opts.rootRoutes || opts.routes,
},
}),
)}
</Switch>
) : null;
}
Switch中遍历了getRouteElement
函数,该函数则是包裹了Route组件,
function getRouteElement({ route, index, opts }: IGetRouteElementOpts) {
const routeProps = {
key: route.key || index,
exact: route.exact,
strict: route.strict,
sensitive: route.sensitive,
path: route.path,
};
if (route.redirect) {
return <Redirect {...routeProps} from={route.path} to={route.redirect} />;
} else {
// 其他代码
return (
<Route
{...routeProps}
render={(props: object) => {
return render({ route, opts, props });
}}
/>
);
}
}
主要看render函数代码
function render({
route,
opts,
props,
}: {
route: IRoute;
opts: IOpts;
props: any;
}) {
// 递归渲染子组件
const routes = renderRoutes(
{
...opts,
routes: route.routes || [],
rootRoutes: opts.rootRoutes,
},
{ location: props.location },
);
// 渲染当前组件
let { component: Component, wrappers } = route;
if (Component) {
const defaultPageInitialProps = opts.isServer
? {}
: (window as any).g_initialProps;
const newProps = {
...props,
...opts.extraProps,
...(opts.pageInitialProps || defaultPageInitialProps),
route,
routes: opts.rootRoutes,
};
// @ts-ignore
let ret = <Component {...newProps}>{routes}</Component>;
// 包裹wrappers
if (wrappers) {
let len = wrappers.length - 1;
while (len >= 0) {
ret = createElement(wrappers[len], newProps, ret);
len -= 1;
}
}
return ret;
} else {
return routes;
}
}
renderRoutes递归渲染子路由,当组件component存在的时候,则包裹子组件,形成嵌套。当wrappers存在,则从右到左包裹component。
那问题来了,routes参数从哪里来。
可以看到[umi项目临时文件路径]/umi.ts
中获取到的getRoutes都是经过处理,那是如何生成了的呢?前面我们说过umi会提前生成这些临时文件,猜测是有某个插件钩子生成了routes,那我们初始化过程只注册了@umijs/preset-built-in
这个插件集,找到这个库的入口文件,发现了有一些插件,从注释来看是用来生成文件的。这些插件集中有个关于routes的。
// generate files
require.resolve('./plugins/generateFiles/core/history'),
require.resolve('./plugins/generateFiles/core/plugin'),
require.resolve('./plugins/generateFiles/core/polyfill'),
require.resolve('./plugins/generateFiles/core/routes'),
require.resolve('./plugins/generateFiles/core/umiExports'),
require.resolve('./plugins/generateFiles/core/configTypes'),
require.resolve('./plugins/generateFiles/umi'),
看到routes的插件
api.onGenerateFiles(async (args) => {
const routesTpl = readFileSync(join(__dirname, 'routes.tpl'), 'utf-8');
const routes = await api.getRoutes();
api.writeTmpFile({
path: 'core/routes.ts',
content: Mustache.render(routesTpl, {
routes: new Route().getJSON({ routes, config: api.config, cwd }),
runtimePath,
config: api.config,
loadingComponent:
api.config.dynamicImport &&
api.config.dynamicImport.loading &&
winPath(api.config.dynamicImport.loading),
}),
});
});
这里用routes模板生成了临时文件中的routes数据。可以看到api调用了getRoutes方法,显然是在哪个地方注入进去的。
在packages/preset-built-in/src/plugins/routes.ts
中果然找到了该钩子
api.registerMethod({
name: 'getRoutes',
async fn() {
const route = new Route({
async onPatchRoutesBefore(args: object) {
await api.applyPlugins({
key: 'onPatchRoutesBefore',
type: api.ApplyPluginsType.event,
args,
});
},
async onPatchRoutes(args: object) {
await api.applyPlugins({
key: 'onPatchRoutes',
type: api.ApplyPluginsType.event,
args,
});
},
async onPatchRouteBefore(args: object) {
await api.applyPlugins({
key: 'onPatchRouteBefore',
type: api.ApplyPluginsType.event,
args,
});
},
async onPatchRoute(args: object) {
await api.applyPlugins({
key: 'onPatchRoute',
type: api.ApplyPluginsType.event,
args,
});
},
});
return await api.applyPlugins({
key: 'modifyRoutes',
type: api.ApplyPluginsType.modify,
initialValue: await route.getRoutes({
config: api.config,
root: api.paths.absPagesPath!,
}),
});
},
});
该钩子提供了路由渲染生命周期的一些修改路由的钩子函数。modify调用了route.getRoutes来获取路由数据。
代码中还有new Route().getJSON({ routes, config: api.config, cwd }),
这段代码,根据路径找到getJSON这段的代码,主要操作变更component路径,提供按需加载的操作等,最终形成routes临时最终的样子。
export default function ({ routes, config, cwd, isServer }: IOpts) {
// 因为要往 routes 里加无用的信息,所以必须 deep clone 一下,避免污染
const clonedRoutes = lodash.cloneDeep(routes);
// 提供按需加载的功能
if (config.dynamicImport) {
patchRoutes(clonedRoutes);
}
function patchRoutes(routes: IRoute[]) {
routes.forEach(patchRoute);
}
function patchRoute(route: IRoute) {
// component非函数时候
if (route.component && !isFunctionComponent(route.component)) {
const webpackChunkName = routeToChunkName({
route,
cwd,
});
// 解决 SSR 开启动态加载后,页面闪烁问题
if (isServer && config?.dynamicImport) {
route._chunkName = webpackChunkName;
}
route.component = [
route.component,
webpackChunkName,
route.path || EMPTY_PATH,
].join(SEPARATOR);
}
if (route.routes) {
patchRoutes(route.routes);
}
}
function isFunctionComponent(component: string) {
return (
/^((.+)?)(\s+)?=>/.test(component) ||
/^function([^(]+)?(([^)]+)?)([^{]+)?{/.test(component)
);
}
// 替换具体实现
function replacer(key: string, value: any) {
switch (key) {
case 'component':
if (isFunctionComponent(value)) return value;
if (config.dynamicImport) {
const [component, webpackChunkName] = value.split(SEPARATOR);
let loading = '';
if (config.dynamicImport.loading) {
loading = `, loading: LoadingComponent`;
}
return isServer
? `require('${component}').default`
: `dynamic({ loader: () => import(/* webpackChunkName: '${webpackChunkName}' */'${component}')${loading}})`;
} else {
return `require('${value}').default`;
}
case 'wrappers':
const wrappers = value.map((wrapper: string) => {
if (config.dynamicImport) {
let loading = '';
if (config.dynamicImport.loading) {
loading = `, loading: LoadingComponent`;
}
return isServer
? `require('${wrapper}').default`
: `dynamic({ loader: () => import(/* webpackChunkName: 'wrappers' */'${wrapper}')${loading}})`;
} else {
return `require('${wrapper}').default`;
}
});
return `[${wrappers.join(', ')}]`;
default:
return value;
}
}
// 通过正则替换component按需加载的路径
return JSON.stringify(clonedRoutes, replacer, 2)
.replace(/"component": ("(.+?)")/g, (global, m1, m2) => {
return `"component": ${m2.replace(/^/g, '"')}`;
})
.replace(/"wrappers": ("(.+?)")/g, (global, m1, m2) => {
return `"wrappers": ${m2.replace(/^/g, '"')}`;
})
.replace(/\r\n/g, '\r\n')
.replace(/\n/g, '\r\n');
}
约定式路由
v3.umijs.org/zh-CN/docs/…官网文档说明了约定式路由的使用方式,接下来看看约定式路由到底是如何实现的。
在 packages/preset-built-in/src/plugins/routes
在注册getRoutes方法时,调用modifyRoutes插件中调用了route.getRoutes来获取数据,route是Route(非react-router)实例化的对象,根据引用路径找到该类。
在packages/core/src/Route/Route.ts
中找到getRoutes代码
async getRoutes(opts: IGetRoutesOpts) {
const { config, root, componentPrefix } = opts;
// 避免修改配置里的 routes,导致重复 patch
let routes = lodash.cloneDeep(config.routes);
let isConventional = false;
// config配置不提供routes的时候,开启约定式路由
if (!routes) {
routes = this.getConventionRoutes({
root: root!,
config,
componentPrefix,
});
isConventional = true;
}
await this.patchRoutes(routes, {
...opts,
isConventional,
});
return routes;
}
找到生成约定式路由的函数,在getConventionalRoutes.ts
文件中
export default function getRoutes(opts: IOpts) {
const { root, relDir = '', config } = opts;
// 获取可以配置成路由的文件
const files = getFiles(join(root, relDir));
// 生成规范化路由,fileToRouteReducer递归处理文件路径
const routes = normalizeRoutes(
files.reduce(fileToRouteReducer.bind(null, opts), []),
);
// relDir = ''
if (!relDir) {
const globalLayoutFile = getFile({
base: root,
fileNameWithoutExt: `../${config.singular ? 'layout' : 'layouts'}/index`,
type: 'javascript',
});
// 存在全局layout
if (globalLayoutFile) {
return [
normalizeRoute(
{
path: '/',
component: globalLayoutFile.path,
routes,
},
opts,
),
];
}
}
return routes;
}
getFiles
操作:
function getFiles(root: string) {
if (!existsSync(root)) return [];
return readdirSync(root).filter((file) => {
const absFile = join(root, file);
const fileStat = statSync(absFile);
const isDirectory = fileStat.isDirectory();
const isFile = fileStat.isFile();
if (
isDirectory &&
['components', 'component', 'utils', 'util'].includes(file)
) {
return false;
}
if (file.charAt(0) === '.') return false;
if (file.charAt(0) === '_') return false;
// exclude test file
if (/.(test|spec|e2e).(j|t)sx?$/.test(file)) return false;
// d.ts
if (/.d.ts$/.test(file)) return false;
if (isFile) {
if (!/.(j|t)sx?$/.test(file)) return false;
const content = readFileSync(absFile, 'utf-8');
try {
if (!isReactComponent(content)) return false;
} catch (e) {
throw new Error(
`Parse conventional route component ${absFile} failed, ${e.message}`,
);
}
}
return true;
});
}
主要获取符合约定规则的文件,满足以下任意规则的文件不会被注册为路由,
- 以 . 或 _ 开头的文件或目录
- 以 d.ts 结尾的类型定义文件
- 以 test.ts、spec.ts、e2e.ts 结尾的测试文件(适用于 .js、.jsx 和 .tsx 文件)
- components 和 component 目录
- utils 和 util 目录
- 不是 .js、.jsx、.ts 或 .tsx 文件
- 文件内容不包含 JSX 元素
接着对返回的files参数进一步处理,主要是识别是否是动态路由,如果是文件夹,校验是否存在_layout.tsx
,存在则生成嵌套路由,不存在直接返回路由对象。
const RE_DYNAMIC_ROUTE = /^[(.+?)]/;
function fileToRouteReducer(opts: IOpts, memo: IRoute[], file: string) {
const { root, relDir = '' } = opts;
const absFile = join(root, relDir, file);
const stats = statSync(absFile);
// 校验是否是动态路由
const __isDynamic = RE_DYNAMIC_ROUTE.test(file);
// 如果是文件夹
if (stats.isDirectory()) {
const relFile = join(relDir, file);
// 如果目录下有_layout文件,这生成嵌套路由
const layoutFile = getFile({
base: join(root, relFile),
fileNameWithoutExt: '_layout',
type: 'javascript',
});
const route = {
path: normalizePath(relFile, opts),
// 递归调用生成子路由
routes: getRoutes({
...opts,
relDir: join(relFile),
}),
__isDynamic,
...(layoutFile
? {
component: layoutFile.path,
}
: {
exact: true,
__toMerge: true,
}),
};
memo.push(normalizeRoute(route, opts));
} else {
const bName = basename(file, extname(file));
memo.push(
normalizeRoute(
{
path: normalizePath(join(relDir, bName), opts),
exact: true,
component: absFile,
__isDynamic,
},
opts,
),
);
}
return memo;
}
// 规范化路径
function normalizePath(path: string, opts: IOpts) {
path = winPath(path)
.split('/')
.map((p) => {
// dynamic route
p = p.replace(RE_DYNAMIC_ROUTE, ':$1');
// :post$ => :post?
if (p.endsWith('$')) {
p = p.slice(0, -1) + '?';
}
return p;
})
.join('/');
path = `/${path}`;
// /index/index -> /
if (path === '/index/index') {
path = '/';
}
// /xxxx/index -> /xxxx/
path = path.replace(//index$/, '/');
// remove the last slash
// e.g. /abc/ -> /abc
if (path !== '/' && path.slice(-1) === '/') {
path = path.slice(0, -1);
}
return path;
}
// 规范化路由component
function normalizeRoute(route: IRoute, opts: IOpts) {
let props: unknown = undefined;
if (route.component) {
try {
// 读取组件,获取文件静态属性,比如title,wrappers等
props = getExportProps(readFileSync(route.component, 'utf-8'));
} catch (e) {}
route.component = winPath(relative(join(opts.root, '..'), route.component));
route.component = `${opts.componentPrefix || '@/'}${route.component}`;
}
return {
...route,
...(typeof props === 'object' ? props : {}),
};
}
normalizePath根据文件的路径生成规范化的path格式。normalizeRoute用于规范化component格式为类似@/pages/user/index
,同时读取导出文件的静态属性作为route的一些参数。
接着看normalizeRoutes。
function normalizeRoutes(routes: IRoute[]): IRoute[] {
const paramsRoutes: IRoute[] = [];
const exactRoutes: IRoute[] = [];
const layoutRoutes: IRoute[] = [];
routes.forEach((route) => {
const { __isDynamic, exact } = route;
delete route.__isDynamic;
if (__isDynamic) {
// 参数路由
paramsRoutes.push(route);
} else if (exact) {
// 完全匹配的路由
exactRoutes.push(route);
} else {
// 不需要完全匹配的路由,比如布局组件
layoutRoutes.push(route);
}
});
return [...exactRoutes, ...layoutRoutes, ...paramsRoutes].reduce(
(memo, route) => {
// 有_toMerge字段需要提一层,不需要嵌套一个routes对象
if (route.__toMerge && route.routes) {
memo = memo.concat(route.routes);
} else {
// 存在布局组件,保持原有的嵌套结构
memo.push(route);
}
return memo;
},
[] as IRoute[],
);
}
该函数用来最后一步规范化路由对象,先按类型划分路由,用来决定路由匹配的先后顺序。
route._toMerge的作用是什么,_toMerge是fileToRouteReduce中设置的,当文件下不存在布局文件(即_layout.tsx),类似如下的路由:
└── pages
├── index.tsx
└── users.tsx
通过文件扫描生成的路由对象大致为:
{
routes:[
{ exact: true, path: '/', component: '@/pages/index' },
{ exact: true, path: '/users', component: '@/pages/users' },
]
}
实际上,由于没有_layout.tsx文件,所以不用进行嵌套,需要提一层,直接展开routes对象到上一层结构
总结:约定式路由的实现方式就是递归扫描特定文件夹的目录结构,然乎根据规则生成规范化的路由格式。
到此路由生成和渲染过程完成。
参考资料: