挑战21天手写前端框架 day11 应用元数据与约定式路由

1,086 阅读7分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第12天,点击查看活动详情

阅读本文需要 7 分钟,编写本文耗时 2 小时

框架生命周期

生命周期这个词,相信对于现代前端开发人员来说是相当常见的一个词,比如在早期学习 React 的时候,就需要掌握 React 组件的生命周期,每个生命周期做了什么,什么事情在什么生命周期勾子中调用,这些都是基本技能了。但是对于框架的生命周期,对于做业务交付的朋友来说,其实关注度不高。但是对于框架开发者或者框架生态共建者,框架的生命周期确实一个非常重要的信息点。比如 umi 中的插件化开发,本质上就是在一个插件中响应不同的生命周期函数。

因为我们这次的目的是掌握前端框架开发基本技能,并不是要用这个框架去支撑什么业务开发。因此,我们的生命周期会用到什么就加什么。一定让读者掌握为什么要增加某个生命周期环节。

在这个框架的“用户配置”的设计上也会沿用上述的理念。用到什么加什么,而不是在一开始就设计一个很完善的配置和扩展能力。

极简的生命周期

根据我们前几天的开发工作和框架抽离工作,我们可以整理出当前我们需要的极简的生命周期。这只是在我们当前的实现中,做的一个整理和规划。但是却可以让 malita 变成一个真正的框架。云谦说的:动态生成的入口文件,框架才有了意义。

  1. 获取应用元数据
  2. 获取路由配置(约定式路由)
  3. 动态生成应用主入口文件
  4. 动态生成 HTML
  5. 执行构建

因为我们的每一步在后续扩展的时候,都可能会涉及到延时和等待,因此我们将每一个生命周期都写成 Promise

这样我们就可以在代码中,使用 async 和 await 来控制整体的构建流程,保证我们的框架完全按照我们设计的生命周期执行。

比如:

malitaServe.listen(port, async () => {
    // 生命周期
    // 获取项目元信息 
    const appData = await getAppData();
});
export const getAppData = ()=>{
    return new Promise((resolve: (value: AppData) => void, rejects)=>{
        const appData = {}
        resolve(appData);
    }
}

获取应用元数据

应用元数据的目的就是把后续所有生命周期中需要用到初始的信息提前获取到,使得后续不同的构建流程能用相同的数据,比如构建路径和端口号这种信息,我们都可以放到应用元数据中。

其实可以放的数据非常的丰富。像 Umi 4 就通过 api.appData 收集各种项目数据,从配置、路由、package.json、tsconfig.json、npmClient 到数据流、国际化、antd 用了哪个版本、react 和 react-dom 的版本等,应有尽有,这对于插件开发者会非常实用,也适用于有统计需求的场景。

不过我们当前的框架还用不到这么多的信息,我们只需要用到一些基础路径和 pkg 信息。

元数据 paths

存放一些项目相关的路径。

cwd

当前项目路径,也就是命令执行的路径,比如你在项目根目录下执行 malita dev 那 cwd 就是项目根目录了。

const app = express();

absSrcPath

项目 src 目录的绝对路径,由于我们要求用户的项目都是按照最佳实践组织的,所以要求这些文件都存在。我们的 src 路径可以作为主路径,后续如果需要用到一些其他的约定的路径,都可以用这个主路径去拼接。

const absSrcPath = path.resolve(cwd, 'src');

absPagesPath

pages 目录绝对路径,约定的路由文件存放路径。

const absPagesPath = path.resolve(absSrcPath, 'pages');

absNodeModulesPath

node_modules 目录绝对路径

const absNodeModulesPath = path.resolve(cwd, 'node_modules');

absTmpPath

临时目录绝对路径,因为我们临时生成的文件不需要被 git 管理,所以放到了 node_modules 路径下,这样就不用在 .gitignore 文件中处理了。

import { DEFAULT_TEMPLATE, } from './constants';
const absTmpPath = path.resolve(absNodeModulesPath, DEFAULT_TEMPLATE);

absEntryPath

主入口文件的绝对路径,因为我们的主入口文件是动态创建的,所以放到了临时目录下。

import { DEFAULT_ENTRY_POINT, } from './constants';
const absEntryPath = path.resolve(absTmpPath, DEFAULT_ENTRY_POINT);

absOutputPath

输出目录绝对路径。

import { DEFAULT_OUTDIR, } from './constants';
const absOutputPath = path.resolve(cwd, DEFAULT_OUTDIR);

pkg 应用信息

存放着当前项目的 package.json 里的信息,这一次我们会使用到 name 作为页面的标题。

const pkg = require(path.resolve(cwd, 'package.json'));

实现

import path from 'path';
import { DEFAULT_ENTRY_POINT, DEFAULT_OUTDIR, DEFAULT_TEMPLATE, } from './constants';

interface Options {
    cwd: string;
}
export interface AppData {
    paths: {
        cwd: string;
        absSrcPath: string;
        absPagesPath: string;
        absTmpPath: string;
        absOutputPath: string;
        absEntryPath: string;
        absNodeModulesPath: string;
    },
    pkg: any,
}
export const getAppData = ({
    cwd
}: Options) => {
    return new Promise((resolve: (value: AppData) => void, rejects) => {
        const absSrcPath = path.resolve(cwd, 'src');
        const absPagesPath = path.resolve(absSrcPath, 'pages');
        const absNodeModulesPath = path.resolve(cwd, 'node_modules');
        const absTmpPath = path.resolve(absNodeModulesPath, DEFAULT_TEMPLATE);
        const absEntryPath = path.resolve(absTmpPath, DEFAULT_ENTRY_POINT);
        const absOutputPath = path.resolve(cwd, DEFAULT_OUTDIR);

        const paths = {
            cwd,
            absSrcPath,
            absPagesPath,
            absTmpPath,
            absOutputPath,
            absEntryPath,
            absNodeModulesPath
        }
        const pkg = require(path.resolve(cwd, 'package.json'));
        resolve({ paths, pkg })
    });
}

获取路由配置(约定式路由)

absPagesPath 路径下查找路由文件,然后生成路由配置信息。想法很简单,先找出所有的文件,然后筛选出所有的 tsx 文件,用文件名作为路由,生成路由配置信息。通过找到约定式的全局 layout ,把它当作根路由,嵌套上面生成的路由配置信息,最终返回。

获取文件信息

首先我们找到所有的文件,判断如果路径不存在,则返回一个空的路由配置信息。然后判断文件是不是是不是包含 /\.tsx?$/,如果不包含,就过滤掉。这里我们对约定式做了简化,没对嵌套的情况还有 pages 目录下存放非路由文件等情况作处理,是为了让我们的思路保持清晰,后续在实现前端数据流的时候我们会完善这个方法。

const getFiles = (root: string) => {
    if (!existsSync(root)) return [];
    return readdirSync(root).filter((file) => {
        const absFile = path.join(root, file);
        const fileStat = statSync(absFile);
        const isFile = fileStat.isFile();
        if (isFile) {
            if (!/\.tsx?$/.test(file)) return false;
        }
        return true;
    });;
}

将文件信息转成路由配置

然后将我们获取到的文件信息转换成正确路由配置信息,因为只有一层路径,所以实现起来非常简单,只要获取到文件名就行了。然后我们之前将 home 页面作为首页,所以这里加了一个处理,后续我们会改成默认 index 和支持用户配置。

const filesToRoutes = (files: string[], pagesPath: string): IRoute[] => {
    return files.map(i => {
        let pagePath = path.basename(i, path.extname(i));
        const element = path.resolve(pagesPath, pagePath);
        if (pagePath === 'home') pagePath = '';
        return {
            path: `/${pagePath}`,
            element,
        }
    });
}

获取约定 layout

我们约定了 absSrcPath 路径下面的 layouts/index.tsx 作为我们的全局 layout,因此我们使用 absSrcPath 拼接出正确的路径。其实这个也可以放到应用元数据中,这里演示一下后面的其他约定路径是如何使用 absSrcPath 的。

const layoutPath = path.resolve(appData.paths.absSrcPath, DEFAULT_GLOBAL_LAYOUTS);

实现

export const getRoutes = ({ appData }: { appData: AppData }) => {
    return new Promise((resolve: (value: IRoute[]) => void) => {
        const files = getFiles(appData.paths.absPagesPath);
        const routes = filesToRoutes(files, appData.paths.absPagesPath);
        const layoutPath = path.resolve(appData.paths.absSrcPath, DEFAULT_GLOBAL_LAYOUTS);
        // 如果不存在全局 layout 那就直接返回路由配置信息
        if (!existsSync(layoutPath)) {
            resolve(routes);
        } else {
            resolve([{
                path: '/',
                element: layoutPath.replace(path.extname(layoutPath), ''),
                routes: routes
            }]);
        }
    })
}

调试一下生命周期流程是否正确

当前项目结构

.
├── dist
├── src
│   ├── layout
│   │   ├── inedx.tsx
│   │   └── index.less
│   └── pages
│       ├── users.tsx
│       ├── users.css
│       ├── home.css
│       └── home.tsx
├── node_modules
├── package.json
├── tsconfig.json
└── typings.d.ts

执行生命周期

malitaServe.listen(port, async () => {
    // 生命周期
    // 获取项目元信息  
    const appData = await getAppData({
        cwd
    });
    console.log(appData)
    // 获取 routes 配置
    const routes = await getRoutes({ appData });
    console.log(JSON.stringify(routes))
});

appData 展示

{
  paths: {
    cwd: '/malita/examples/app',
    absSrcPath: '/malita/examples/app/src',
    absPagesPath: '/malita/examples/app/src/pages',
    absTmpPath: '/malita/examples/app/node_modules/.malita',
    absOutputPath: '/malita/examples/app/dist',
    absEntryPath: '/malita/examples/app/node_modules/.malita/malita.tsx',
    absNodeModulesPath: '/malita/examples/app/node_modules'
  },
  pkg: {
    name: '@examples/app',
    version: '1.0.0',
    description: '',
    main: 'index.js',
    scripts: {
      build: 'pnpm esbuild src/** --bundle --outdir=www',
      dev: 'malita dev',
      serve: 'cd dist && serve'
    },
    keywords: [],
    author: '',
    dependencies: {
      '@alita/flow': '^3.0.0-beta.5',
      malita: 'workspace:*',
      '@malita/keepalive': 'workspace:*',
      react: '^18.0.0',
      'react-dom': '^18.0.0',
      'react-router': '^6.3.0',
      'react-router-dom': '^6.3.0'
    },
    devDependencies: { serve: '^13.0.2' }
  }
}

getFiles 展示

通过 getFiles 获取到文件组织树信息。

[ 'home.tsx', 'users.tsx' ]

filesToRoutes 展示

通过 filesToRoutes 将上面的信息转成下面的路由配置信息。

[
  {
    path: '/',
    element: '/malita/examples/app/src/pages/home'
  },
  {
    path: '/users',
    element: '/malita/examples/app/src/pages/users'
  }
]

由于篇幅问题,今天就只完成了两个生命周期,还有 “动态生成应用主入口文件” 和 “动态生成 HTML”,我们会在下一天完成。

今天家里的小可爱们长了跳蚤,要早点回去驱虫。明天周六不更这个系列,下一次更新在后天晚上10点左右。今天题图会附上我家最小的小可爱。

以前也在掘金上发过几片文章,但是并没有真正关注过掘金,只是随手一发,想着多一个平台而已。真正开始玩掘金 12 天了,很认真的感受了一下掘金的氛围,感觉真的是做的很好的一个技术社区了。今天好几个朋友找我,说在掘金首页的作者榜单看到我了,感觉好几年没写东西了,文笔和书写逻辑已经比不上之前了,还好朋友们不嫌弃,鼓励多余嘲讽,这让我很有成就感的。如果你对我的有所期待,请给我点赞和关注。感谢感谢。

感谢阅读,祝大家有一个愉快的周末。

源码归档