接上回@umijs/core源码解读,我们启动一个简单的工程。.umirc.ts配置文件中的所有配置都可以删掉
本文将介绍部分插件的内容preset-umi、bundler-vite、bundler-webpack
插件
mustache
为模版文件填词
connect
http中间件,允许服务有express一样的执行方式。app.use
服务端渲染
webpack-dev-middleware
文档。允许webpack在其它服务中作为中间件的方式接入
@umijs/preset-umi
umi项目主要插件集
启动一个简单的项目只需要以下几个插件即可
// 注册事件
// 代码生成的逻辑也在里面
require.resolve('./registerMethods'),
// 初始化配置文件里的一些配置,并加载到appData上
// 例如路由、启动指令、是否是vite、mfsu等
require.resolve('./features/appData/appData'),
// 启动服务webpack和vite的配置
// 设置配置文件部分字段校验规则
// 一些基础配置设置初始值
require.resolve('./features/configPlugins/configPlugins'),
// (不重要)终端日志输出样式调整
require.resolve('./features/terminal/terminal'),
// 生成一些主要的文件 .umi/路径下
require.resolve('./features/tmpFiles/tmpFiles'),
// 启动指令配置,收集外部插件和配置项,调用webpack或vite启动服务
require.resolve('./commands/dev/dev'),
registerMethods
除了配置事件外、有个writeTmpFile函数。其是生成运行时文件的逻辑,src/pages/.umi文件夹下的内容就是通过该方法创建的
// 获取需要创建文件的绝对路径
const absPath = join(
api.paths.absTmpPath,
// @ts-ignore
this.plugin.key && !opts.noPluginDir ? `plugin-${this.plugin.key}` : '',
opts.path,
);
// 如果没有创建文件夹,则递归创建文件夹
// 因为如果a/b/c.ts b文件夹没有,那么创建c.ts文件会报错
fsExtra.mkdirpSync(dirname(absPath));
let content = opts.content;
// 如果没有内容
if (!content) {
// 就去获取模版内容
const tpl = opts.tplPath
? readFileSync(opts.tplPath, 'utf-8')
: opts.tpl;
// 然后为模版填词
content = Mustache.render(tpl, opts.context);
}
// 生成的文件如果是ts,则不检查
content = [
isTypeScriptFile(opts.path) && `// @ts-nocheck`,
'// This file is generated by Umi automatically',
'// DO NOT CHANGE IT MANUALLY!',
content.trim(),
'',
]
.filter((text: string | boolean) => text !== false)
.join('\n');
// vite中不能识别绝对路径导入,转换为相对路径
// transform imports for all javascript-like files only vite mode enable
if (api.appData.vite && /.(t|j)sx?$/.test(absPath)) {
content = transformIEAR({ content, path: absPath }, api);
}
// 如果以前没创建过,则直接写入内容
if (!existsSync(absPath)) {
writeFileSync(absPath, content, 'utf-8');
} else {
// 如果以前有创建过,获取,然后内容比对,相同则不需要更改
const fileContent = readFileSync(absPath, 'utf-8');
if (fileContent.startsWith('// debug') || fileContent === content) {
return;
} else {
writeFileSync(absPath, content, 'utf-8');
}
}
appData
- modifyAppData:初始化appData的内容,包含路由和全局js/css等
api.modifyAppData(async (memo) => {
// 获取配置文件的路由,并处理
memo.routes = await getRoutes({
api,
});
memo.apiRoutes = await getApiRoutes({
api,
});
memo.hasSrcDir = api.paths.absSrcPath.endsWith('/src');
// 执行指令是npm还是pnpm
memo.npmClient = api.userConfig.npmClient || getNpmClient({ cwd: api.cwd });
// 记录一下umi的运行版本,在控制台输出
memo.umi = {
version: require('../../../package.json').version,
name: 'Umi',
importSource: 'umi',
};
memo.bundleStatus = {
done: false,
};
if (api.config.mfsu !== false) {
memo.mfsuBundleStatus = {
done: false,
};
}
// react版本和路径别名中取到的绝对路径
memo.react = {
version: require(join(api.config.alias.react, 'package.json')).version,
path: api.config.alias.react,
};
memo['react-dom'] = {
version: require(join(api.config.alias['react-dom'], 'package.json'))
.version,
path: api.config.alias['react-dom'],
};
// src/app.[jt]sx?
memo.appJS = await getAppJsInfo();
// 获取操作系统的语言(中文)
memo.locale = await osLocale();
memo.vite = api.config.vite ? {} : undefined;
// 全局js和css,global.ts/global.css[less...]
const { globalCSS, globalJS } = getGlobalFiles();
memo.globalCSS = globalCSS;
memo.globalJS = globalJS;
// 向上查找到git目录
const gitDir = findGitDir(api.paths.cwd);
if (gitDir) {
const git: Record<string, string> = {};
const configPath = join(gitDir, 'config');
// 获取git的源
if (existsSync(configPath)) {
const config = readFileSync(configPath, 'utf-8');
const url = parse(config)['remote "origin"']?.url;
if (url) {
git.originUrl = url;
}
}
memo.git = git;
}
// 指定运行时框架
memo.framework = 'react';
return memo;
});
该文件下面还有两个hookonGenerateFiles、updateAppDataDeps用于调整appData的一些属性
- getRoutes
export async function getRoutes(opts: { api: IApi }) {
let routes = null;
// 配置式路由
if (opts.api.config.routes) {
routes = getConfigRoutes({
routes: opts.api.config.routes,
// 该过程将文件补全后缀
// 同时调整为@/路径
onResolveComponent(component) {
// 转换Component路径
if (component.startsWith('@/')) {
component = component.replace('@/', '../');
}
// 补全后缀
component = winPath(
resolve.sync(localPath(component), {
basedir: opts.api.paths.absPagesPath,
extensions: ['.js', '.jsx', '.tsx', '.ts', '.vue'],
}),
);
// 调整回@/,绝对路径vite不能编译
component = component.replace(`${opts.api.paths.absSrcPath}/`, '@/');
return component;
},
});
} else {
// 约定式路由
// 约定在pages下的文件都可以为路由入口
routes = getConventionRoutes({
base:
opts.api.config.conventionRoutes?.base || opts.api.paths.absPagesPath,
exclude: opts.api.config.conventionRoutes?.exclude,
prefix: '',
});
}
// routers
// 此时得到的约定式路由是平铺在对象中,没有嵌套
// 路由的属性至少有
// path: string,
// id: string,
// parentId?: string,
// file?: string,
// absPath: string
function localPath(path: string) {
if (path.charAt(0) !== '.') {
return `./${path}`;
} else {
return path;
}
}
// 将页面内容读取出来
for (const id of Object.keys(routes)) {
if (routes[id].file) {
// TODO: cache for performance
let file = routes[id].file;
const basedir =
opts.api.config.conventionRoutes?.base || opts.api.paths.absPagesPath;
if (!isAbsolute(file)) {
if (file.startsWith('@/')) {
file = file.replace('@/', '../');
}
// 补全文件路径
file = resolve.sync(localPath(file), {
basedir,
extensions: ['.js', '.jsx', '.tsx', '.ts', '.vue'],
});
}
// 设置文件内容
const isJSFile = /.[jt]sx?$/.test(file);
routes[id].__content = readFileSync(file, 'utf-8');
routes[id].__absFile = winPath(file);
// 是否是js/ts文件
routes[id].__isJSFile = isJSFile;
if (opts.api.config.ssr || opts.api.config.clientLoader) {
routes[id].__exports =
isJSFile && existsSync(file)
? await getModuleExports({
file,
})
: [];
}
if (opts.api.config.ssr) {
routes[id].hasServerLoader =
routes[id].__exports.includes('serverLoader');
}
if (opts.api.config.clientLoader) {
routes[id].__hasClientLoader =
routes[id].__exports.includes('clientLoader');
routes[id].clientLoader = `clientLoaders['${id}']`;
}
}
}
// layout routes
const absSrcPath = opts.api.paths.absSrcPath;
// 如果有layout/index文件,获取路径
const absLayoutPath = tryPaths([
join(opts.api.paths.absSrcPath, 'layouts/index.tsx'),
join(opts.api.paths.absSrcPath, 'layouts/index.vue'),
]);
// 装载整体布局
const layouts = (
await opts.api.applyPlugins({
key: 'addLayouts',
initialValue: [
absLayoutPath && {
id: '@@/global-layout',
file: winPath(absLayoutPath),
},
].filter(Boolean),
})
).map((layout: { file: string }) => {
// prune local path prefix, avoid mix in outputs
layout.file = layout.file.replace(new RegExp(`^${absSrcPath}`), '@');
return layout;
});
// 将布局加载到routers路由之上
for (const layout of layouts) {
addParentRoute({
addToAll: true,
target: {
id: layout.id,
path: '/',
file: layout.file,
parentId: undefined,
absPath: '/',
},
routes,
test: layout.test,
});
}
// patch routes
for (const id of Object.keys(routes)) {
await opts.api.applyPlugins({
key: 'onPatchRoute',
args: {
route: routes[id],
},
});
}
// 将路由结果发送给插件,如果插件要改,则获取调整后的结果
routes = await opts.api.applyPlugins({
key: 'modifyRoutes',
initialValue: routes,
});
return routes;
}
configPlugins
// reactDOM库的绝对路径
const reactDOMPath =
resolveProjectDep({
pkg: api.pkg,
cwd: api.cwd,
dep: 'react-dom',
}) || dirname(require.resolve('react-dom/package.json'));
const reactDOMVersion = require(join(reactDOMPath, 'package.json')).version;
const isLT18 = !reactDOMVersion.startsWith('18.');
// 为一些基础配置设置初始值
const configDefaults: Record<string, any> = {
alias: {
// umi指向pages/.umi文件
umi: '@@/exports',
// 这里pnpm时可以在不用引入react的相关插件时直接使用
react:
resolveProjectDep({
pkg: api.pkg,
cwd: api.cwd,
dep: 'react',
}) || dirname(require.resolve('react/package.json')),
...(isLT18
? {
'react-dom/client': reactDOMPath,
}
: {}),
'react-dom': reactDOMPath,
'react-router': dirname(require.resolve('react-router/package.json')),
'react-router-dom': dirname(
require.resolve('react-router-dom/package.json'),
),
},
externals: {},
autoCSSModules: true,
publicPath: '/',
mountElementId: 'root',
base: '/',
history: { type: 'browser' },
svgr: {},
};
// vite/webpack配置校验Schemas配置
const bundleSchemas = api.config.vite
? getViteSchemas()
: getWebpackSchemas();
// 其它项配置校验Schemas配置
const extraSchemas = getExtraSchemas();
const schemas = {
...bundleSchemas,
...extraSchemas,
};
// 遍历schemas,创建规则插件添加到servers插件项中
for (const key of Object.keys(schemas)) {
const config: Record<string, any> = {
schema: schemas[key] || ((Joi: any) => Joi.any()),
};
if (key in configDefaults) {
config.default = configDefaults[key];
}
api.registerPlugins([
{
id: `virtual: config-${key}`,
key: key,
config,
},
]);
}
// 添加别名
// @ -> src/
// @@ -> pages/.umi
// api.paths is ready after register
api.modifyConfig((memo, args) => {
memo.alias = {
...memo.alias,
'@': args.paths.absSrcPath,
'@@': args.paths.absTmpPath,
};
return memo;
});
- getConventionRoutes:获取约定式路由配置
export function getConventionRoutes(opts: {
base: string;
prefix?: string;
// 排除
exclude?: RegExp[];
}) {
// 文件名路径(不包含后缀):文件名路径
// 即路由对应的文件路径
const files: { [routeId: string]: string } = {};
if (!(existsSync(opts.base) && statSync(opts.base).isDirectory())) {
return {};
}
visitFiles({
dir: opts.base,
visitor: (file) => {
const routeId = createRouteId(file);
if (isRouteModuleFile({ file: winPath(file), exclude: opts.exclude })) {
files[routeId] = winPath(file);
}
},
});
// 路由长度排序
const routeIds = Object.keys(files).sort(byLongestFirst);
function defineNestedRoutes(defineRoute: any, parentId?: string) {
// 获取子路由集合
const childRouteIds = routeIds.filter(
// findParentRouteId方法为获取到以我为前缀的
// 那你就是我的孩子
(id) => findParentRouteId(routeIds, id) === parentId,
);
for (let routeId of childRouteIds) {
// createRoutePath为解析路由,大概就是
// 去 index ,动态参数 :id 的形式, * 通配符
// 函数注释写的很清楚
let routePath = createRoutePath(
parentId ? routeId.slice(parentId.length + 1) : routeId,
);
// 公用同一个函数,该函数产生自defineRoutes
// 形成闭包
// 依次处理每个路由,创建route对象,将路由添加至闭包变量routes中
defineRoute({
path: routePath,
file: `${opts.prefix || ''}${files[routeId]}`,
children() {
defineNestedRoutes(defineRoute, routeId);
},
});
}
}
return defineRoutes(defineNestedRoutes);
}
tmpFiles
里面代码比较多,大概就是创建一些基础的运行时文件。
其中exports.ts文件是用来暴露umi的api
import { OutLet } from 'react-router-dom'
// ->
import { OutLet } from 'umi'
umi.ts是项目前端启动文件,即浏览器第一个执行的js文件
dev
服务通知完onStart后,调用fn
- 删除pages/.umi文件夹
- 校验应用的package.json
- 通知可以开始生成.umi文件夹下的内容了
- 收集通过
addTmpGenerateWatcherPaths添加的.umi下需要监听变化的文件,收集pages、layout等 - 收集的文件添加变化监听,如果文件发生变化,修改.umi生成文件
- 监听应用package.json
- 添加配置文件变化监听,允许在重启应用时移除该监听
- 监听自定义的plugin.ts文件
- 通知服务(webpack/vite)即将启动
- 收集中间件、babel插件等
- 创建获取(webpack/vite)配置的通知
- 将所有配置整合起来,调用服务启动函数
if (enableVite) {
await bundlerVite.dev(opts);
} else {
await bundlerWebpack.dev(opts);
}
其它
api.modifyAppData(async (memo) => {
// portfinder用于查找可用端口
memo.port = await portfinder.getPortPromise({
port: parseInt(String(process.env.PORT || DEFAULT_PORT), 10),
});
memo.host = process.env.HOST || DEFAULT_HOST;
return memo;
});
// 如果收到重启服务的通知
api.registerMethod({
name: 'restartServer',
fn() {
logger.info(`Restart dev server with port ${api.appData.port}...`);
// 通知所有监听(文件)关闭
unwatch();
// 随后重启项目
// umi中会重新执行/umi/src/cli/fork.ts的start方法
// 即重新创建服务,并run
process.send?.({
type: 'RESTART',
payload: {
port: api.appData.port,
},
});
},
});
api.modifyViteConfig((viteConfig) => {
// 在vite配置中,添加vite插件,处理html
viteConfig.plugins?.push(ViteHtmlPlugin(api));
return viteConfig;
});
bundler-vite
我们先来看下/bundler-vite/src/dev.ts
getConfig
初始化了一些vite插件和兼容配置
externals
// 引入虚拟文件,使得js可以通过如下方式获取挂载在window上的部分变量
// import external from 'externals[id]'
export default function externals(
externals: NonNullable<IConfig['externals']>,
): Plugin {
return {
name: 'bundler-vite:externals',
// 加载某个module时转换成另一个module
resolveId(id) {
if (externals[id]) {
return id;
}
},
// 加载指定module时直接返回内容
load(id) {
if (externals[id]) {
return `const external = window.${externals[id]};export default external;`;
}
},
};
}
autoCSSModulePlugin,使vite的cssModule支持可用省略.module写法
import style from './style.less' // =>
import style from './style.less?.module.css'
configTransformer:调整用户配置,就是做umi和vite配置名称不一样的兼容处理rename:重命名,将一些配置转为vite能识别的配置alias:别名处理react:如果不是vue项目,则添加@vitejs/plugin-react插件,并传入babel插件等
其它不再赘述
将vite配置传给其它插件处理,然后返回处理后的配置结果,执行createServer
createServer
这里的逻辑和上面提到的vite服务端渲染很像,这里也不再赘述
bundler-webpack
插件流程和bundler-vite相似,但配置方面做了跟多的处理,多了mfsu缓存提速