挑战21天手写前端框架 day6 能跑的前端框架

简介: 挑战21天手写前端框架 day6 能跑的前端框架

image.png


阅读本文需要 20 分钟,编写本文耗时 3 小时。因为第一次尝试纯使用 esbuild 编写框架,翻阅文档的时间较长。


直接进入主题吧,昨天我们遗留了一个问题:执行 malita dev 提供一个可访问链接能访问当前页面。


今天我们的第一件事情就是来处理这个解决这个问题。

node 构建 web 服务用到的就是 node 的 http 服务,现在就有很多基于 http 模块构建的一些简单易上手的 node 服务端编写工具。


像 express,koa,egg 等都是很优秀的开源项目,我对这几个框架都没有特殊偏好,所以我随便选一个 express 吧。


发现好多朋友,提到 node 的服务端,就会以为它是一个提供 api 访问,操作数据库,类似 java 的功能。

但其实我们的前端框架服务也是用它来完成的。



一个简单的 express

首先我们需要安装 express 模块

cd packages/malita
pnpm i express


然后编写我们的 dev 入口,packages/malita/src/dev

import express from 'express';
export const dev = async () => {
    const app = express();
    app.listen(8888, async () => {
        console.log(`App listening at http://127.0.0.1:8888`)
    });
}


看起来代码非常简单,但是它确实已经完成了,一个 web 服务。

执行构建

cd packages/malita
pnpm dev


修改 cli action packages/malita/bin/malita.js#L30

-    require('../lib/dev')
+    const {
+        dev
+    } = require('../lib/dev');
+    dev();


修改执行脚本 examples/app/package.json

"scripts": {
        "build": "pnpm esbuild src/** --bundle --outdir=www",
-       "dev": "pnpm build -- --watch",
+       "dev": "malita dev",
        "serve": "cd www && serve"
    },


执行命令

cd examples/app
pnpm dev


可以看到控制台打印了

> malita dev
App listening at http://127.0.0.1:8888


用浏览器打开 http://127.0.0.1:8888 你将会看到 Cannot GET / 的提示,如果没有 web 服务的话,你将会看到 无妨访问此网站 的提示。

image.png



Cannot GET /

既然提示我们没有主路径服务,那么我们就来编写一个简单的服务吧。

import express from 'express';
export const dev = async () => {
    const app = express();
    app.get('/', (_req, res) => {
        res.send('Helo Malita!');
    })
    app.listen(8888, async () => {
        console.log(`App listening at http://127.0.0.1:8888`)
    });
}


此时你再次访问 http://127.0.0.1:8888 将会看到页面上显示了 Helo Malita!

你可以尝试着修改,返回你想要返回的任意内容。

当然我们将会用他来返回我们首页的 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>
        </body>
        </html>`);
    });


重启 malita dev 服务,刷新页面,此时你将会在页面上看到了 loading...

恭喜你,你学会了服务端渲染技术。

常见面试题,服务端渲染的原理:将项目构建成 html 字符串,然后返回给浏览器。


当然,我们现在返回的只是一个静态的 html,接下来让我们加入 js 的部分内容。

"build": "pnpm esbuild src/** --bundle --outdir=www",
复制代码

昨天我们使用的是 cli 的方式调用 esbuild,当然我们依旧可以在 node 服务中调用 cli 命令,但是为了阅读上的体验和代码逻辑流程的可控性,我们还是使用引入 esbuild 的方式来构建我们的项目。



静态常量定义

根据上面的构建脚本和 express 服务,我们整理出需要的常量,将它们统一放在一起维护。

packages/malita/src/constants.ts

export const DEFAULT_OUTDIR = 'www';
export const DEFAULT_ENTRY_POINT = 'src/index.tsx';
export const DEFAULT_FRAMEWORK_NAME = 'malita';
export const DEFAULT_PLATFORM = 'browser';
export const DEFAULT_HOST = '127.0.0.1';
export const DEFAULT_PORT = 8888;
export const DEFAULT_BUILD_PORT = 8989;



一个能跑的框架

好了上面提到的知识点,你已经会了吧,稍微整理一下,加一点点细节,你就能编写出下面的代码了。

import express from 'express';
import { serve, build } from 'esbuild';
import type { ServeOnRequestArgs } from 'esbuild';
import path from "path";
import { DEFAULT_ENTRY_POINT, DEFAULT_OUTDIR, DEFAULT_PLATFORM, DEFAULT_PORT, DEFAULT_HOST, DEFAULT_BUILD_PORT } from './constants';
export const dev = async () => {
    const cwd = process.cwd();
    const app = express();
    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="http://${DEFAULT_HOST}:${DEFAULT_BUILD_PORT}/index.js"></script>
        </body>
        </html>`);
    });
    app.listen(DEFAULT_PORT, async () => {
        console.log(`App listening at http://${DEFAULT_HOST}:${DEFAULT_PORT}`)
        try {
            const devServe = await serve({
                port: DEFAULT_BUILD_PORT,
                host: DEFAULT_HOST,
                servedir: DEFAULT_OUTDIR,
                onRequest: (args: ServeOnRequestArgs) => {
                    if (args.timeInMS) {
                        console.log(
                            `${args.method}: ${args.path} ${args.timeInMS} ms`
                        );
                    }
                },
            }, {
                format: 'iife',
                logLevel: 'error',
                outdir: DEFAULT_OUTDIR,
                platform: DEFAULT_PLATFORM,
                bundle: true,
                define: {
                    'process.env.NODE_ENV': JSON.stringify('development'),
                },
                entryPoints: [path.resolve(cwd, DEFAULT_ENTRY_POINT)],
            });
            process.on('SIGINT', () => {
                devServe.stop();
                process.exit(0);
            });
            process.on('SIGTERM', () => {
                devServe.stop();
                process.exit(1);
            });
        } catch (e) {
            console.log(e);
            process.exit(1);
        }
    });
}

哈哈哈,开个玩笑。不啰嗦就不是我的风格了。



使用 import from esbuild

"build": "pnpm esbuild src/** --bundle --outdir=www",


首先我们拆解一下上面的命令

import { build } from 'esbuild';
build({
    outdir: 'www',
    bundle: true,
    entryPoints: ['src/index.tsx'],
})

使用 package 中的命令,路径都是从根目录开始查找的,但是使用 dev 服务就可能是在任意的目录,所以首先我们要找到文件的正确位置。


所以将 entryPoints 修改成 [path.resolve(process.cwd(), DEFAULT_ENTRY_POINT)].


因为我们是一个开发服务,所以为了体验上更好构建更迅速,我们可以考虑将文件生成在内存中而不是写到磁盘里,有两个原因,首先“写完”意味着使用时就要再“读取”,然后有个谣言是 esbuild serve 会以更高性能的方式(从内存而不是从磁盘)为您的构建目录提供服务


所以我们使用 esbuild.serve 替代 esbuild.build

import { serve } from 'esbuild';
serve({
    port: DEFAULT_BUILD_PORT,
    host: DEFAULT_HOST,
    servedir: DEFAULT_OUTDIR,
    }, {
    outdir: 'www',
    bundle: true,
    entryPoints: ['src/index.tsx'],
});


由于我们构建的是 React 项目,React 在项目中使用到了 process.env.NODE_ENV 环境变量,因此我们可以使用 esbuild 的 define 定义将它替换成真实的值。

define: {
    'process.env.NODE_ENV': JSON.stringify('development'),
},


因为我们的构建是在 web 服务启动之后,再执行的,因此我们将它放在了 app.listen 的回掉中执行,因为要保证脚本退出的时候正确中止服务,所以讲了两个监听回掉。

app.listen(DEFAULT_PORT, async () => {
        console.log(`App listening at http://${DEFAULT_HOST}:${DEFAULT_PORT}`)
        try {
            const devServe = await serve();
            process.on('SIGINT', () => {
                devServe.stop();
                process.exit(0);
            });
            process.on('SIGTERM', () => {
                devServe.stop();
                process.exit(1);
            });
        } catch (e) {
            console.log(e);
            process.exit(1);
        }
    });


因为期望在访问页面时,有更多合理的日志,所以增加了 onRequest 配置。

onRequest: (args: ServeOnRequestArgs) => {
    console.log(
        `${args.method}: ${args.path} ${args.timeInMS} ms`
    );
},


ServeOnRequestArgs 参数属性为

{
    method: 'GET',
    path: '/index.js',
    remoteAddress: '127.0.0.1:55868',
    status: 200,
    timeInMS: 30
}


esbuild.serve 会讲构建产物输出到 ${host}:${port} 服务上,因此我们在返回的 html 中增加对 js 的引用

<script src="http://${DEFAULT_HOST}:${DEFAULT_BUILD_PORT}/index.js"></script>



esbuild 编译 esbuild 错误

执行构建(packages/malita),提示找不到一些 node 模块,如

✘ [ERROR] Could not resolve "path"
✘ [ERROR] Could not resolve "events"
✘ [ERROR] Could not resolve "http"


仔细看日志,上面会有解决方案

The package "path" wasn't found on the file system but is built into node. Are
  you trying to bundle for node? You can use "--platform=node" to do that, which
  will remove this error.


根据提示修改我们的构建脚本

// packages/malita/package.json
"scripts": {
    "build": "pnpm esbuild src/** --bundle --outdir=lib --platform=node",
    "dev": "pnpm build -- --watch"
},


再次执行,有一个警告,但是构建完成了。 然后我们执行 examples/app 中的 pnpm dev。 又得到一个新的错误,说 esbuild 需要被 external

Error: The esbuild JavaScript API cannot be bundled. Please mark the "esbuild" package as external so it's not included in the bundle.
复制代码


我们再次修改构建脚本

// packages/malita/package.json
"scripts": {
    "build": "pnpm esbuild ./src/** --bundle --outdir=lib --platform=node --external:esbuild",
    "dev": "pnpm build -- --watch"
},

再次执行 examples/app 中的 pnpm dev 验证。

发现一切正常运行了。至此我们就完成了一个能跑的前端框架了。



问题

1、服务端口被占用,最常见的在于我们同时开启两个服务,你会见到如下提示:

App listening at http://127.0.0.1:8888
Error: listen tcp4 0.0.0.0:8888: bind: address already in use

2、每次修改项目都需要刷新页面

我们将会在下一篇文章中解决它们。



总结回顾

回顾这几天的历程,你会不会觉得框架开发就是一个发现问题然后解决问题的过程呢?

就像很多人会问我一个问题 "umi 插件做了什么?我想写一个 umi 插件要怎么写?"

这是一个很难回答的问题,或者我觉得这是一个提问角度不太正确的问题。

首先如果问题是 “umi 的 keepalive 插件做了啥? umi 的 antd 插件做了啥?umi 的 dva 插件做了啥?”


这些问题在提问的时候就已经得到了答案。

然后下一步问题就是类似 “umi 的 keepalive 插件怎么做的?”,也能够按步骤介绍,它的功能,它的实现,它调用的 umi 的 api 这些答案稍微组织一下就可以很清晰的答复。


至于“我想写一个 umi 插件要怎么写?”,我都会反问他们:“你想写一个什么插件”,有趣的是得到最多的回答是“不知道,就是想写一个插件练练手”。

回到我们的主题,我们挑战21天手写前端框架,就会面临一个问题:要写一个怎样的前端框架呢?


切换一下角度,我们在项目开发中遇到什么样的问题呢?我想着21天我会告诉大家这个问题的答案。

那你在项目开发中有遇到什么问题呢?是否可以通过这个框架来实现?欢迎在评论区和我互动。


感谢阅读,今天是挑战日更的第六天,发现完成起来比我想象中的要困难的多。但是成就感和习惯的养成是比较明显的。


源码归档

目录
相关文章
|
JSON 前端开发 JavaScript
前端AJAX入门到实战,学习前端框架前必会的(ajax+node.js+webpack+git)(一)
前端AJAX入门到实战,学习前端框架前必会的(ajax+node.js+webpack+git)(一)
589 0
|
1月前
|
JavaScript 前端开发 网络架构
|
3月前
|
前端开发 JavaScript 程序员
后端程序员的前端捷径-超级容易上手使用的前端框架layUI(上)
后端程序员的前端捷径-超级容易上手使用的前端框架layUI
55 10
|
3月前
|
前端开发 JavaScript 程序员
后端程序员的前端捷径-超级容易上手使用的前端框架layUI(下)
后端程序员的前端捷径-超级容易上手使用的前端框架layUI
71 9
|
4月前
|
JSON 前端开发 JavaScript
【amis低代码前端框架】vue2集成百度低代码前端框架amis
【amis低代码前端框架】vue2集成百度低代码前端框架amis
499 0
|
6月前
|
前端开发 JavaScript 开发者
【专栏:HTML与CSS前端技术趋势篇】前端框架(React/Vue/Angular)与HTML/CSS的结合使用
【4月更文挑战第30天】前端框架React、Vue和Angular助力UI开发,通过组件化、状态管理和虚拟DOM提升效率。这些框架与HTML/CSS结合,使用模板语法、样式管理及组件化思想。未来趋势包括框架简化、Web组件标准采用和CSS在框架中角色的演变。开发者需紧跟技术发展,掌握新工具,提升开发效能。
106 11
|
设计模式 JavaScript 前端开发
前端(十一)——Vue vs. React:两大前端框架的深度对比与分析
前端(十一)——Vue vs. React:两大前端框架的深度对比与分析
657 0
|
6月前
|
前端开发 JavaScript
前端年度需求最大的前端框架都有那些?
【4月更文挑战第7天】 前端框架如React、Angular、Vue.js和Svelte各有优势,选择需考虑项目需求、团队经验、社区支持、性能和学习曲线。React适合高性能UI,Angular适合大型企业应用,Vue.js轻量且易学,Svelte则以高性能著称。活跃社区、丰富的第三方库和良好文档是重要考量因素。
69 0
|
存储 缓存 JavaScript
前端(八)——深入探索前端框架中的Diff算法:优化视图更新与性能提升
前端(八)——深入探索前端框架中的Diff算法:优化视图更新与性能提升
280 0
|
前端开发 JavaScript API
理解前端框架、前端库,两者有什么区别
理解前端框架、前端库,两者有什么区别
379 0
下一篇
无影云桌面