标题来自云谦的星球
阅读本文需要 5 分钟,编写本文耗时 1 小时
极简的生命周期
- 获取应用元数据
- 获取路由配置(约定式路由)
- 动态生成应用主入口文件
- 动态生成 HTML
- 执行构建
昨天我们已经完成了 “获取应用元数据” 和 “获取路由配置(约定式路由)”,今天我们就直接进入主题,看看两个动态生成流程是如何实现的。
动态生成应用主入口文件
首先我们重新指定我们的项目主入口,因为是临时生成的,因此我们将它放到 absTmpPath
临时目录中。
packages/malita/src/constants.ts
- export const DEFAULT_ENTRY_POINT = 'malita.tsx'; + export const DEFAULT_ENTRY_POINT = 'src/index.tsx'; 复制代码
其实我们的需求是非常清晰的,就是通过之前获取到的路由数据,生成我们现在的入口文件,即 examples/app/src/index.tsx
。
我们需要写一个工具,将 routes 配置,转换成真实可用的代码。
[ path: '/', element: '/malita/examples/app/src/layouts/index' routes: [{ path: '/', element: '/malita/examples/app/src/pages/home' }, { path: '/users', element: '/malita/examples/app/src/pages/users' } ] ] 复制代码
转换为
import React from 'react'; import ReactDOM from 'react-dom/client'; import { HashRouter, Routes, Route, } from 'react-router-dom'; import KeepAliveLayout from '@malitajs/keepalive'; import Layout from './layouts/index'; import Hello from './pages/home'; import Users from './pages/users'; const App = () => { return ( <KeepAliveLayout keepalive={[/./]}> <HashRouter> <Routes> <Route path='/' element={<Layout />}> <Route path="/" element={<Hello />} /> <Route path="/users" element={<Users />} /> </Route> </Routes> </HashRouter> </KeepAliveLayout> ); } const root = ReactDOM.createRoot(document.getElementById('malita')); root.render(React.createElement(App)); 复制代码
仔细观察,其实我们需要关注的仅仅是与组件相关的这几行代码的动态生成。
import Layout from './layouts/index'; import Hello from './pages/home'; import Users from './pages/users'; <Route path='/' element={<Layout />}> <Route path="/" element={<Hello />} /> <Route path="/users" element={<Users />} /> </Route> 复制代码
只要根据配置生成对应的字符串即可。由于 esbuild 不转换 ast,所以我们这里做了一个简化,给导入页面随便写一个名字。
import A1 from './layouts/index'; 复制代码
简单是实现如下:
let count = 1; const getRouteStr = (routes: IRoute[]) => { let routesStr = ''; let importStr = ''; routes.forEach(route => { count += 1; importStr += `import A${count} from '${route.element}';\n`; routesStr += `\n<Route path='${route.path}' element={<A${count} />}>`; if (route.routes) { const { routesStr: rs, importStr: is } = getRouteStr(route.routes); routesStr += rs; importStr += is; } routesStr += '</Route>\n'; }) return { routesStr, importStr }; } 复制代码
最终我们就可以得到,整个文件的字符串如下:
const { routesStr, importStr } = getRouteStr(routes); const content = ` import React from 'react'; import ReactDOM from 'react-dom/client'; import { HashRouter, Routes, Route, } from 'react-router-dom'; import KeepAliveLayout from '@malitajs/keepalive'; ${importStr} const App = () => { return ( <KeepAliveLayout keepalive={[/./]}> <HashRouter> <Routes> ${routesStr} </Routes> </HashRouter> </KeepAliveLayout> ); } const root = ReactDOM.createRoot(document.getElementById('malita')); root.render(React.createElement(App)); `; 复制代码
然后将字符串写到对应文件中即可writeFileSync(appData.paths.absEntryPath, content, 'utf-8');
指的注意的是当目标文件的所在文件夹不存在的时候,是无法完成文件写入的,所以我们可以先创建目标文件所在的文件夹。这个在微生成器的实现部分,是一个非常需要注意的地方。
import { mkdir, writeFileSync } from 'fs'; let count = 1; const getRouteStr = (routes: IRoute[]) => {} export const generateEntry = ({ appData, routes }: { appData: AppData; routes: IRoute[] }) => { return new Promise((resolve, rejects) => { count = 0; const { routesStr, importStr } = getRouteStr(routes); const content = ` import React from 'react'; import ReactDOM from 'react-dom/client'; import { HashRouter, Routes, Route, } from 'react-router-dom'; import KeepAliveLayout from '@malitajs/keepalive'; ${importStr} const App = () => { return ( <KeepAliveLayout keepalive={[/./]}> <HashRouter> <Routes> ${routesStr} </Routes> </HashRouter> </KeepAliveLayout> ); } const root = ReactDOM.createRoot(document.getElementById('malita')); root.render(React.createElement(App)); `; try { mkdir(path.dirname(appData.paths.absEntryPath), { recursive: true }, (err) => { if (err) { rejects(err) } writeFileSync(appData.paths.absEntryPath, content, 'utf-8'); resolve({}) }); } catch (error) { rejects({}) } }) } 复制代码
动态生成 HTML
还是一样的思路,动态生成,我们还是依照模版分析。我们要从之前获取到的数据,生成我们需要的 html 字符串。
app.get('/', (_req, res) => { res.set('Content-Type', 'text/html'); res.send(`<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Malita</title> </head> <body> <div id="malita"> <span>loading...</span> </div> <script src="/${DEFAULT_OUTDIR}/index.js"></script> <script src="/malita/client.js"></script> </body> </html>`); }); 复制代码
这里我们取 package.json
中的 name
作为页面的 title
然后,修改引入 js 的真实路径,并将 html 写到根路径的 index.html
。
import { mkdir, writeFileSync } from 'fs'; import path from 'path'; import type { AppData } from './appData'; import { DEFAULT_FRAMEWORK_NAME, DEFAULT_OUTDIR } from './constants'; export const generateHtml = ({ appData }: { appData: AppData; }) => { return new Promise((resolve, rejects) => { const content = ` <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>${appData.pkg.name ?? 'Malita'}</title> </head> <body> <div id="malita"> <span>loading...</span> </div> <script src="/${DEFAULT_OUTDIR}/${DEFAULT_FRAMEWORK_NAME}.js"></script> <script src="/malita/client.js"></script> </body> </html>`; try { const htmlPath = path.resolve(appData.paths.absOutputPath, 'index.html') mkdir(path.dirname(htmlPath), { recursive: true }, (err) => { if (err) { rejects(err) } writeFileSync(htmlPath, content, 'utf-8'); resolve({}) }); } catch (error) { rejects({}) } }) } 复制代码
执行构建
单独看这几个生命周期,会觉得它们相互之间的关联性并不是太强烈,会有带着一个的疑问:为什么要这么写,为什么要生成这个文件?这我们在构建环节就会将这几个数据和临时文件串联到一起。
malitaServe.listen(port, async () => { console.log(`App listening at http://${DEFAULT_HOST}:${port}`); try { // 生命周期 // 获取项目元信息 const appData = await getAppData({ cwd }); // 获取 routes 配置 const routes = await getRoutes({ appData }); // 生成项目主入口 await generateEntry({ appData, routes }); // 生成 Html await generateHtml({ appData }); // 执行构建 await build({ // 没修改的配置,这里简略了,不是删除了哦 outdir: appData.paths.absOutputPath, entryPoints: [appData.paths.absEntryPath], }); } catch (e) { console.log(e); process.exit(1); } }); 复制代码
我们使用 esbuild 将新的项目主入口,构建到产物路径中,因此我们要同步的修改,我们的 html 获取方法。
const output = path.resolve(cwd, DEFAULT_OUTDIR); app.get('/', (_req, res, next) => { res.set('Content-Type', 'text/html'); const htmlPath = path.join(output, 'index.html'); if (fs.existsSync(htmlPath)) { fs.createReadStream(htmlPath).on('error', next).pipe(res); } else { next(); } }); 复制代码
判断 html 是否生成成功,再返回 html。
fs.createReadStream(htmlPath).on('error', next).pipe(res);
来自辟殊 (pshu)
新的问题产生了
我们将应用主入口从项目中,移到了框架中,当前的问题就是我们之前的 配置,被写死到了,生成的临时文件中。
我们的页面 title,也是我们默认的取的 package.json
中的 name
这可能并不是用户想要的。
因此这些数据应该从项目传到框架中,因此就出现了“用户配置”需求。这内容我们会在明天实现。
感谢阅读,今天的内容主要是补充了昨天缺漏的实现。对于实现,也仅仅是实现我们此刻的需求和场景。但正是因为简单,反而更适合新手阅读。不知道这样的编写方式,你是觉得适合你呢,还是太简单啰嗦了呢?我期待你的反馈。