一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第14天,点击查看活动详情。
标题来自云谦的星球
阅读本文需要 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)
新的问题产生了
我们将应用主入口从项目中,移到了框架中,当前的问题就是我们之前的 <KeepAliveLayout keepalive={[/./]}>
配置,被写死到了,生成的临时文件中。
我们的页面 title,也是我们默认的取的 package.json
中的 name
这可能并不是用户想要的。
因此这些数据应该从项目传到框架中,因此就出现了“用户配置”需求。这内容我们会在明天实现。
感谢阅读,今天的内容主要是补充了昨天缺漏的实现。对于实现,也仅仅是实现我们此刻的需求和场景。但正是因为简单,反而更适合新手阅读。不知道这样的编写方式,你是觉得适合你呢,还是太简单啰嗦了呢?我期待你的反馈。