阅读本文需要 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天我会告诉大家这个问题的答案。
那你在项目开发中有遇到什么问题呢?是否可以通过这个框架来实现?欢迎在评论区和我互动。
感谢阅读,今天是挑战日更的第六天,发现完成起来比我想象中的要困难的多。但是成就感和习惯的养成是比较明显的。