阅读本文需要 7 分钟,编写本文耗时 2 小时
框架生命周期
生命周期这个词,相信对于现代前端开发人员来说是相当常见的一个词,比如在早期学习 React 的时候,就需要掌握 React 组件的生命周期,每个生命周期做了什么,什么事情在什么生命周期勾子中调用,这些都是基本技能了。但是对于框架的生命周期,对于做业务交付的朋友来说,其实关注度不高。但是对于框架开发者或者框架生态共建者,框架的生命周期确实一个非常重要的信息点。比如 umi 中的插件化开发,本质上就是在一个插件中响应不同的生命周期函数。
因为我们这次的目的是掌握前端框架开发基本技能,并不是要用这个框架去支撑什么业务开发。因此,我们的生命周期会用到什么就加什么。一定让读者掌握为什么要增加某个生命周期环节。
在这个框架的“用户配置”的设计上也会沿用上述的理念。用到什么加什么,而不是在一开始就设计一个很完善的配置和扩展能力。
极简的生命周期
根据我们前几天的开发工作和框架抽离工作,我们可以整理出当前我们需要的极简的生命周期。这只是在我们当前的实现中,做的一个整理和规划。但是却可以让 malita 变成一个真正的框架。云谦说的:动态生成的入口文件,框架才有了意义。
- 获取应用元数据
- 获取路由配置(约定式路由)
- 动态生成应用主入口文件
- 动态生成 HTML
- 执行构建
因为我们的每一步在后续扩展的时候,都可能会涉及到延时和等待,因此我们将每一个生命周期都写成 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”,我们会在下一天完成。
如果你对我的有所期待,请给我点赞和关注。感谢感谢。