之前写过几篇 Vite 的文章,对 Vite 的概念也有一定的理解了,但理解归理解,仍然觉得很虚,也不知怎么的,这几个概念突然就变成一个这么强大的工具。。。
于是,我决定自己手写一遍 Vite,这样才有实在感,而且为了往往要考虑兼容各种情况,源码往往会非常复杂,不利于理解。那么这时候,手写一遍,去掉这些兼容逻辑、边界判断等,只关注核心逻辑,就能进一步地加深理解。
本文是这个系列的第一篇文章,在本篇文章中,我们先不关注 Vite 的架构,因为我们得先有个东西出来,对于很多人来说,空谈架构是不行的。
因此,我们首先把 Vite 开发环境的部分功能模仿出来:实现 Vite Dev Server,并能够对请求的 ts 文件做编译。
下篇文章,我们再来讲述,如何给这个手写的 Vite 加入架构相关的内容。
本文用到的仓库存放在该 GitHub[1] 仓库,感兴趣的可以自行下载
项目约定
我们既然要手写 Vite,那当然要有一个 my-vite
的项目,我们还需要一个调试 Vite 的前端页面项目。
我打算把手写 Vite,做成一个系列,代码都放到一个仓库中,因此我使用 monorepo
来管理这些项目。
这里做如下的目录约定:
代码语言:javascript复制└─packages
└─ 1. my-vite-xxx
├─playground
└─ 2. my-vite-xxx
├─playground
└─ ……
- • 所有版本的手写 Vite 项目都放在 packages 中
- • 每个手写 Vite 项目中,会有一个
playground
文件夹用来存放调试用的前端页面项目
本文用到的例子为 1.my-vite-simple-server
以及该文件夹里面的 playground
调试用的页面项目
在手写 Vite 之前,我们构造一个极其简单的前端页面,用最简单的项目来说明 Vite 的核心流程
index.html
文件:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div id="app"></div>
</body>
<script type="module" src="src/main.js"></script>
</html>
main.js
文件
// playground/src/main.js
import { subModule } from './sub-module.js';
const app = document.getElementById('app');
if (app) {
app.innerText = 'Hello World';
}
subModule(app);
sub-module.js
文件:
// playground/src/sub-module.js
export function subModule(app) {
console.log('this is a subModule');
app.innerHTML = '<Br/> this is a subModule';
}
如何运行 Vite 命令
当我们使用 Vite 时,在 package.json
使用如下命令,即可在开发环境运行 Vite:
{
"scripts": {
"dev": "vite",
},
}
要实现 Vite 命令,说实话有点复杂,我们要给 my-vite
做一个 bin 脚本,另外我们用 TS 写的代码,还得将代码编译成 JS,Vite 还没写就整这么多无关的东西,这多不好鸭。
那我们换个思路, package.json
改成这样:
{
"scripts": {
"dev": "esno ../vite.ts",
},
}
我们直接用 esno
运行一个 TS 脚本,这样即不需要做一个 bin 脚本,也不需要编译 ts 代码,这对我们理解核心逻辑是有帮助的。
我们就把 vite.ts
当做是运行了 vite
命令,然后我们在 vite.ts
脚本中写 Vite 命令实际执行的内容即可。
开启一个 Server
Vite 在开发环境下,会创建一个 Server,那我们首先也来创建一个 Server。
创建 Server 用 connect
包(Vite 也是使用它创建 Server),它是一个可扩展的 HTTP 服务器框架,使用方式如下:
// /src/node/server/index.ts
import connect from 'connect';
import http from 'http';
export async function createServer(){
const app = connect();
// 每次请求会经过该中间件的处理
app.use(function(_, res){
// 响应请求
res.end('Hello from Connect!n');
});
http.createServer(app).listen(3000);
console.log('open http://localhost:3000/');
}
我们在 vite.ts
进行调用:
// vite.ts
import { createServer } from './src/node/server';
createServer();
然后在 playground
中运行:
pnpm run dev
# open http://localhost:3000/
打开 http://localhost:3000/
效果如下:
如果 Network 中有多余的请求,可能是浏览器插件导致的,可以使用无痕模式进行调试。
在这个例子中,无论请求的链接是什么,都会返回 Hello from Connect,因为中间件始终返回同样的内容。
我们这里再稍微介绍一下 Connect 中间件的机制,已经知道的同学也可以跳过。
中间件机制
connect
的中间件机制,可以用如下图表示:
当一个请求发送到 server 时,会经过一个个的中间件,中间件本质是一个回调函数,每次请求都会执行回调。
connect
的中间件机制有如下特点:
- • 每个中间件可以分别对请求进行处理,并进行响应。
- • 每个中间件可以只处理特定的事情,其他事情交给其他中间件处理
- • 可以调用 next 函数,将请求传递给下一个中间件。如果不调用,则之后的中间件都不会被执行
想要实现 Vite Dev Server 的行为,其实就是实现对应能力的中间件
为了先把页面给展示出来,我们先实现文件服务的中间件。
实现文件服务中间件
这里我们直接借助 sirv
[2] 这个包,它是一个非常轻量级中间件,用于处理对静态资源的请求。
// /src/node/server/middlewares/static.ts
import { NextHandleFunction } from 'connect';
import sirv from 'sirv';
export function staticMiddleware(): NextHandleFunction {
const serveFromRoot = sirv('./', { dev: true });
return async (req, res, next) => {
serveFromRoot(req, res, next);
};
}
使用中间件的方式:
代码语言:javascript复制// vite.ts
app.use(staticMiddleware());
然后重新执行 vite.ts
,**重启 Server **(由于我们的 Server 没有做热更新机制,每次修改必须手动重启 Server,代码才会生效),访问 http://localhost:3000
,就能显示出页面了。
这其实就是个平平无奇的文件服务,根据请求的访问路径,读取文件。因为浏览器能直接执行 js 的代码,因此能正确展示页面。
如果我们把 JS 改成 TS。
代码语言:javascript复制<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div id="app"></div>
</body>
- <script type="module" src="src/main.js"></script>
<script type="module" src="src/main.ts"></script>
</html>
main.ts
:
import { subModule } from './sub-module.ts';
const app = document.getElementById('app');
app!.innerText = 'Hello World';
subModule(app!);
sub-module.ts
:
export function subModule(app: HTMLElement) {
console.log('this is a subModule');
app.innerHTML = '<Br/> this is a subModule';
}
这下子页面就出不来了:
因为浏览器无法识别 TS 的语法,自然就报错了。当然这是预期之内的。因为 vite 会在请求中对 TS 进行编译,而我们这里并没有处理。那我们接下来把这个能力补上。
TS 编译中间件
先来写一个中间件的基本结构:
代码语言:javascript复制// /src/node/server/middlewares/transform.ts
export function transformMiddleware(
): NextHandleFunction {
return async function viteTransformMiddleware(req, res, next) {
if (req.method !== 'GET') {
return next();
}
const url: string = cleanUrl(req.url!);
if (
isTsRequest(url)
) {
// 编译 TS 代码
const result = await doTransform(url);
// 设置 header,告诉浏览器把这个请求的响应值,当做 js 运行
res.setHeader('Content-Type', 'application/javascript');
// 响应请求
return res.end(result.code);
}
next();
};
}
只处理 GET 和 TS 的请求,其他的交给下一个中间件处理。
- • 该中间应该放到文件服务中间件之前,因为 TS 的请求,需要进行转换,不应该再走到文件服务了,转换完成后,直接由该中间件进行响应
- • 由于不走文件服务中间件,我们应该自行实现 TS 文件的读取
接下来我们来实现 doTransform
函数:
import { transform } from 'esbuild';
import path from 'path';
import { readFile } from 'fs-extra';
export async function doTransform(url: string) {
const file = url.startsWith('/') ? '.' url : url;
// 读取文件
const rawCode = await readFile(file, 'utf-8');
const { code, map } = await transform(rawCode, {
target: 'esnext',
format: 'esm',
sourcemap: true,
loader: 'ts',
});
return {
code,
map,
};
}
主要流程如下:
- • 读取文件
- • 转换代码
访问页面,效果如下:
从图中可以看出,TS 已经被转换成 JS 了。
由于 TS 文件被转换了,接下来我们再补一下 sourcemap
export function transformMiddleware(
): NextHandleFunction {
return async function viteTransformMiddleware(req, res, next) {
if (req.method !== 'GET') {
return next();
}
const url: string = cleanUrl(req.url!);
if (
isTsRequest(url)
) {
// 编译 TS 代码
const result = await doTransform(url);
const code = getCodeWithSourcemap(result.code, result.map);
// 设置 header,告诉浏览器把这个请求的响应值,当做 js 运行
res.setHeader('Content-Type', 'application/javascript');
// 响应请求
- return res.end(result.code);
return res.end(code);
}
next();
};
}
getCodeWithSourcemap
的实现如下:
// 生成 sourcemap 的 data url
export function genSourceMapUrl(map: string): string {
return `data:application/json;base64,${Buffer.from(map).toString('base64')}`;
}
// 将 sourcemap 拼接到代码末尾
export function getCodeWithSourcemap(code: string, map: string): string {
code = `n//# sourceMappingURL=${genSourceMapUrl(map)}`;
return code;
}
主要流程如下:
- • 将 esbuild 转换时生成的 map,用 base64 编码后,拼接成 data url。关注 data url 可以看 MDN[3]
- • 把
sourcemap
字符串,拼接到代码末尾
效果如下:
可以看出,打断点时,能映射到源码。
TSX/JSX 编译
由于 esbuild 也能直接处理 tsx、jsx 等语法,我们只需要稍微修改一下 doTransform
,就能用于 tsx、jsx 的转换。
export function transformMiddleware(
): NextHandleFunction {
return async function viteTransformMiddleware(req, res, next) {
if (req.method !== 'GET') {
return next();
}
const url: string = cleanUrl(req.url!);
if (
- isTsRequest(url)
isJsRequest(url)
) {
// 编译 TS 代码
const result = await doTransform(url);
const code = getCodeWithSourcemap(result.code, result.map);
// 设置 header,告诉浏览器把这个请求的响应值,当做 js 运行
res.setHeader('Content-Type', 'application/javascript');
// 响应请求
return res.end(code);
}
next();
};
}
isJSRequest
实现如下:
const knownJsSrcRE = /.((j|t)sx?)$/;
export const isJSRequest = (url: string): boolean => {
return knownJsSrcRE.test(url);
};
doTransform
也需要做相应的修改
import { transform } from 'esbuild';
import path from 'path';
import { readFile } from 'fs-extra';
export async function doTransform(url: string) {
const extname = path.extname(url).slice(1);
const file = url.startsWith('/') ? '.' url : url;
// 读取文件
const rawCode = await readFile(file, 'utf-8');
const { code, map } = await transform(rawCode, {
target: 'esnext',
format: 'esm',
sourcemap: true,
- loader: 'ts',
loader: extname as 'js' | 'ts' | 'jsx' | 'tsx',
});
return {
code,
map,
};
}
那么我们来尝试一下使用 tsx
- 1. 首先先从 CDN 引入 React
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
</head>
<body>
<div id="app"></div>
<div id="react-root"></div>
</body>
<script type="module" src="src/main.ts"></script>
</html>
- 1. 新增 tsx 模块
// react-component.tsx
export function ReactComponent(){
return (
<div>this is a React Component</div>
);
}
- 1. 引入 tsx 模块
import { subModule } from './sub-module.ts';
import {ReactComponent} from './react-component.tsx';
const app = document.getElementById('app');
app!.innerText = 'Hello World';
subModule(app!);
const comp = ReactComponent();
const root = ReactDOM.createRoot(
document.getElementById('react-root')
);
root.render(comp);
重启 Server,效果如下:
可以看到,tsx 已经被正确编译,React 组件被渲染出来了。
处理 CSS 的引入
为了演示 CSS 的相关处理,我们先造一些 CSS 文件
style.css
@import "./style-imported.css";
body{
font-size: 24px;
font-weight: 700;
}
style-imported.css
body{
color: #2196f3;
}
加入 @import
是为了测试 import style
我们在 index.html
引入,先看看效果:
<head>
<link href="src/style.css" rel="stylesheet"></link>
</head>
看完效果,我们得把 html 中的引入删掉,我们要在 js 中引入,并使这种引入方式能够正常生效。
代码语言:javascript复制import { subModule } from './sub-module.ts';
import {ReactComponent} from './react-component.tsx';
import './style.css';
const app = document.getElementById('app');
app!.innerText = 'Hello World';
subModule(app!);
const comp = ReactComponent();
const root = ReactDOM.createRoot(
document.getElementById('react-root')
);
root.render(comp);
众所周知,js 中直接引入 css,是不行的。会得到以下错误:
代码语言:javascript复制Failed to load module script: Expected a JavaScript module script but the server responded with a MIME type of "text/css". Strict MIME type checking is enforced for module scripts per HTML spec.
意思是,用 JS import 的 style.css 请求,它的响应值不是 JS,但浏览器期望它是 JS,这样它才能执行。
那么我们将 CSS 转换成 JS 即可,因此我们需要一个 CSS 转换的中间件。
CSS 中间件
同样的,我们先写一个中间件的基本结构:
代码语言:javascript复制import { NextHandleFunction } from 'connect';
import { isCSSRequest } from '../../utils';
export function cssMiddleware(): NextHandleFunction {
return async function viteTransformMiddleware(req, res, next) {
if (req.method !== 'GET') {
return next();
}
const url: string = req.url!;
if (isCSSRequest(url)) {
// CSS 文件的读取和转换
res.setHeader('Content-Type', 'application/javascript');
return res.end(/* 转换后的代码 */);
}
next();
};
}
接下来补充一下,文件的读取:
代码语言:javascript复制const file = url.startsWith('/') ? '.' url : url;
const rawCode = await readFile(file, 'utf-8');
那如何将 CSS 转换成 JS 模块,让它能够作为 ES6 module 引入呢?
其实很简单,用 JS 将 CSS 的内容,插入到页面即可
代码语言:javascript复制const file = url.startsWith('/') ? '.' url : url;
const rawCode = await readFile(file, 'utf-8');
res.setHeader('Content-Type', 'application/javascript');
return res.end(`
var style = document.createElement('style')
style.setAttribute('type', 'text/css')
style.innerHTML = `${rawCode} `
document.head.appendChild(style)
`);
创建一个 style
标签,内容为 CSS 的文本,然后加入到 document。这样就能把 CSS 当做 JS 模块引入了。
我们来看看效果:
样式渲染出来了,但又没有完全出来。style-imported.css
的字体颜色样式没有渲染出来。
可以看出有 style-imported.css
的请求是失败的,而看看我们写的 Server,也报错了,错误为找不到文件。
因为没有错误处理,整个 Server 直接崩了,进程退出。
我们来看看是什么原因导致的。
可以看出,使用 style-imported.css
的 src 路径没有了,导致读取文件的时候,读取文件的目录不对,找不到 style-imported.css
为什么直接在 html 中引入 CSS 文件正常,用 JS 引入却会发生问题?
要理解这个,就要理解 CSS 的相对 url 的行为,在 MDN[4] 中的描述如下:
相对地址相对于 CSS 样式表的 URL(而不是网页的 URL)。
在 html 引入的 CSS 中,样式表的 URL 为 src/style.css
,则 ./style-imported.css
解析为 src/style-imported.css
。
而作为 JS 模块引入的 CSS,是通过 document.head.appendChild(style)
加入到页面的,不存在 URL,因此不能正确解析相对路径。
那这个问题该如何处理?
两个思路:
- • 在请求响应前,将
@import
的 url 修改为相对于项目根目录的路径。 - • 将
@import
的内容,通过打包,内联到一个 CSS 文件
方案一看起来简单,但实际上需要兼容的情况还是比较多的。
方案二目前其实已经有成熟的方案了,使用 PostCSS 处理即可
在 Vite 内部,实际上是使用了方案二;
最终的实现代码如下:
代码语言:javascript复制import { NextHandleFunction } from 'connect';
import { cleanUrl, isCSSRequest } from '../../utils';
import { readFile } from 'fs-extra';
import postcss from 'postcss';
import atImport from 'postcss-import';
export function cssMiddleware(): NextHandleFunction {
return async function viteTransformMiddleware(req, res, next) {
if (req.method !== 'GET') {
return next();
}
const url: string = cleanUrl(req.url!);
if (isCSSRequest(url)) {
const file = url.startsWith('/') ? '.' url : url;
const rawCode = await readFile(file, 'utf-8');
// 使用 PostCSS 进行处理
const postcssResult = await postcss([atImport()]).process(rawCode,{
from: file,
to:file
});
res.setHeader('Content-Type', 'application/javascript');
return res.end(`
var style = document.createElement('style')
style.setAttribute('type', 'text/css')
style.innerHTML = `${postcssResult.css} `
document.head.appendChild(style)
`);
}
next();
};
}
效果如下:
可以看到,@import
的内容,已经被内联到了 style.css
中
总结
在该文章中,我们首先构造了一个用于调试的项目,然后用一种巧妙的方式,通过 esno
直接运行 vite.ts
脚本, 替代了 vite
命令的实现,简化了我们的实现成本,不需要编译 TS,同时也减少了大家的理解成本。
然后我们开始写 Server 的内容,写了如何启动一个 Server,并简单的介绍了 Connect 的中间件的机制
接下来,使用 sirv
搭建了一个文件服务,把页面展示出来了。
然后我们分别对 TS 和 CSS 进行了处理
- • 对于 TS,我们用 esbuild 进行编译,同时 esbuild 也支持 TSX/JSX 的转换,因此也对此进行兼容,并做了一个小 Demo 进行展示。
- • 对于 CSS,我们先用 PostCSS 进行转换,然后将转换后的代码,处理成 JS 模块,通过创建
style
标签并插入到document
的方式,将style
注入到页面中。这样就能够在 JS 代码中对 CSS 文件进行 import。
至此,我们第一版的 my-vite
就完成了,但其实这距离 Vite,还有非常大的一段距离,我们这次写的 my-vite
,只是一个普普通通的服务,只是实现了看起来跟 Vite 差不多功能的一个东西,里面的逻辑都是写死的,一点扩展性都没有,如果要新增能力,就得修改 my-vite
的代码。
Vite 之所以强大,除了它自身实现的优秀能力外,很大程度是因为其插件式的架构提供设计,提供了极大的可扩展性,可通过插件,对 Vite 能力进行扩展,而不需要对 Vite 自身代码进行修改,例如: @vite/plugin-vue
插件,通过使用该插件,就能够获取到 Vue 文件的编译能力。
因此,本篇文章的 my-vite
,只是把一些能力做出来了,但是毫无架构可言。下篇文章,将会在这个的基础上,逐步地加入一些架构的内容,敬请期待。
最后
如果这篇文章对您有所帮助,请帮忙点个赞