挑战21天手写前端框架 day12 云谦:动态生成的入口文件,框架才有了意义

914 阅读4分钟

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

标题来自云谦的星球

阅读本文需要 5 分钟,编写本文耗时 1 小时

极简的生命周期

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

昨天我们已经完成了 “获取应用元数据” 和 “获取路由配置(约定式路由)”,今天我们就直接进入主题,看看两个动态生成流程是如何实现的。

动态生成应用主入口文件

首先我们重新指定我们的项目主入口,因为是临时生成的,因此我们将它放到 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 这可能并不是用户想要的。

因此这些数据应该从项目传到框架中,因此就出现了“用户配置”需求。这内容我们会在明天实现。

感谢阅读,今天的内容主要是补充了昨天缺漏的实现。对于实现,也仅仅是实现我们此刻的需求和场景。但正是因为简单,反而更适合新手阅读。不知道这样的编写方式,你是觉得适合你呢,还是太简单啰嗦了呢?我期待你的反馈。

源码归档