umijs本地项目启动

679 阅读5分钟

接上回@umijs/core源码解读,我们启动一个简单的工程。.umirc.ts配置文件中的所有配置都可以删掉

本文将介绍部分插件的内容preset-umibundler-vitebundler-webpack

插件

mustache

为模版文件填词

connect

http中间件,允许服务有express一样的执行方式。app.use

服务端渲染

vitewebpack

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缓存提速