umi源码阅读4:routes渲染过程

367 阅读7分钟

umi阅读分为5个部分,分别是:

以下代码以umi的3.5.20版本为例,主要内容以源码+个人解读为主

上一篇:umi源码阅读3:渲染过程

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对象到上一层结构

总结:约定式路由的实现方式就是递归扫描特定文件夹的目录结构,然乎根据规则生成规范化的路由格式。

到此路由生成和渲染过程完成。

下一篇:umi源码阅读5:插件机制

参考资料:

UMI3源码解析系列之构建原理_大转转FE的博客-CSDN博客

umi源码解析

if 我是前端Leader,谈谈前端框架体系建设 - 掘金