挑战21天手写前端框架 day6 能跑的前端框架
阅读本文需要 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 服务的话,你将会看到 无妨访问此网站
的提示。
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天我会告诉大家这个问题的答案。
那你在项目开发中有遇到什么问题呢?是否可以通过这个框架来实现?欢迎在评论区和我互动。
感谢阅读,今天是挑战日更的第六天,发现完成起来比我想象中的要困难的多。但是成就感和习惯的养成是比较明显的。
转载自:https://juejin.cn/post/7086842262600024101