手把手教你手写一个 Vite Server(一)

语言: CN / TW / HK
ead>

之前写过几篇 Vite 的文章,对 Vite 的概念也有一定的理解了,但理解归理解,仍然觉得很虚,也不知怎么的,这几个概念突然就变成一个这么强大的工具。。。

于是,我决定自己手写一遍 Vite,这样才有实在感,而且为了往往要考虑兼容各种情况,源码往往会非常复杂,不利于理解。那么这时候,手写一遍,去掉这些兼容逻辑、边界判断等,只关注核心逻辑,就能进一步地加深理解。

本文是这个系列的第一篇文章,在本篇文章中,我们先不关注 Vite 的架构,因为我们得先有个东西出来,对于很多人来说,空谈架构是不行的

因此,我们首先把 Vite 开发环境的部分功能模仿出来:实现 Vite Dev Server,并能够对请求的 ts 文件做编译。

下篇文章,我们再来讲述,如何给这个手写的 Vite 加入架构相关的内容。

本文用到的仓库存放在该 GitHub 仓库,感兴趣的可以自行下载

项目约定

我们既然要手写 Vite,那当然要有一个 my-vite 的项目,我们还需要一个调试 Vite 的前端页面项目。

我打算把手写 Vite,做成一个系列,代码都放到一个仓库中,因此我使用 monorepo 来管理这些项目

这里做如下的目录约定:

└─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 文件:

```html

Title

```

main.js 文件

```javascript // 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 文件:

javascript // 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:

json { "scripts": { "dev": "vite", }, }

要实现 Vite 命令,说实话有点复杂,我们要给 my-vite 做一个 bin 脚本,另外我们用 TS 写的代码,还得将代码编译成 JS,Vite 还没写就整这么多无关的东西,这多不好鸭。

那我们换个思路, package.json 改成这样:

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 服务器框架,使用方式如下:

```javascript // /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 进行调用:

typescript // vite.ts import { createServer } from './src/node/server'; createServer();

然后在 playground 中运行:

```shell pnpm run dev

open http://localhost:3000/

```

打开 http://localhost:3000/ 效果如下:

image-20220630204222553

如果 Network 中有多余的请求,可能是浏览器插件导致的,可以使用无痕模式进行调试

在这个例子中,无论请求的链接是什么,都会返回 Hello from Connect,因为中间件始终返回同样的内容。

我们这里再稍微介绍一下 Connect 中间件的机制,已经知道的同学也可以跳过。

中间件机制

connect 的中间件机制,可以用如下图表示:

image-20220612104713553

当一个请求发送到 server 时,会经过一个个的中间件,中间件本质是一个回调函数,每次请求都会执行回调。

connect 的中间件机制有如下特点:

  • 每个中间件可以分别对请求进行处理,并进行响应。
  • 每个中间件可以只处理特定的事情,其他事情交给其他中间件处理
  • 可以调用 next 函数,将请求传递给下一个中间件。如果不调用,则之后的中间件都不会被执行

想要实现 Vite Dev Server 的行为,其实就是实现对应能力的中间件

为了先把页面给展示出来,我们先实现文件服务的中间件。

实现文件服务中间件

这里我们直接借助 sirv 这个包,它是一个非常轻量级中间件,用于处理对静态资源的请求。

```typescript // /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); }; } ```

使用中间件的方式:

typescript // vite.ts app.use(staticMiddleware());

然后重新执行 vite.ts重启 Server (由于我们的 Server 没有做热更新机制,每次修改必须手动重启 Server,代码才会生效),访问 http://localhost:3000,就能显示出页面了。

image-20220630214001510

这其实就是个平平无奇的文件服务,根据请求的访问路径,读取文件。因为浏览器能直接执行 js 的代码,因此能正确展示页面。

如果我们把 JS 改成 TS。

```diff

Title

  • ```

main.ts:

```typescript import { subModule } from './sub-module.ts';

const app = document.getElementById('app'); app!.innerText = 'Hello World';

subModule(app!); ```

sub-module.ts

typescript export function subModule(app: HTMLElement) { console.log('this is a subModule'); app.innerHTML += '<Br/> this is a subModule'; }

这下子页面就出不来了:

image-20220630214547538

因为浏览器无法识别 TS 的语法,自然就报错了。当然这是预期之内的。因为 vite 会在请求中对 TS 进行编译,而我们这里并没有处理。那我们接下来把这个能力补上。

TS 编译中间件

先来写一个中间件的基本结构:

```typescript // /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 函数:

```typescript 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, }; } ```

主要流程如下:

  • 读取文件
  • 转换代码

访问页面,效果如下:

image-20220703231044322

从图中可以看出,TS 已经被转换成 JS 了。

由于 TS 文件被转换了,接下来我们再补一下 sourcemap

```diff 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 的实现如下:

``typescript // 生成 sourcemap 的 data url export function genSourceMapUrl(map: string): string { returndata: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
  • sourcemap 字符串,拼接到代码末尾

效果如下:

image-20220703224017482

可以看出,打断点时,能映射到源码。

TSX/JSX 编译

由于 esbuild 也能直接处理 tsx、jsx 等语法,我们只需要稍微修改一下 doTransform ,就能用于 tsx、jsx 的转换。

```diff 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 实现如下:

typescript const knownJsSrcRE = /\.((j|t)sx?)$/; export const isJSRequest = (url: string): boolean => { return knownJsSrcRE.test(url); };

doTransform 也需要做相应的修改

```diff 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

```diff

Title + +

+

```

  1. 新增 tsx 模块

typescript // react-component.tsx export function ReactComponent(){ return ( <div>this is a React Component</div> ); }

  1. 引入 tsx 模块

```diff 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,效果如下:

image-20220703231132930

可以看到,tsx 已经被正确编译,React 组件被渲染出来了。

处理 CSS 的引入

为了演示 CSS 的相关处理,我们先造一些 CSS 文件

style.css

css @import "./style-imported.css"; body{ font-size: 24px; font-weight: 700; }

style-imported.css

css body{ color: #2196f3; }

加入 @import 是为了测试 import style

我们在 index.html 引入,先看看效果:

html <head> <link href="src/style.css" rel="stylesheet"></link> </head>

image-20220704152241102

看完效果,我们得把 html 中的引入删掉,我们要在 js 中引入,并使这种引入方式能够正常生效。

```diff 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,是不行的。会得到以下错误:

js 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 中间件

同样的,我们先写一个中间件的基本结构:

```typescript 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();

}; } ```

接下来补充一下,文件的读取:

typescript const file = url.startsWith('/') ? '.' + url : url; const rawCode = await readFile(file, 'utf-8');

那如何将 CSS 转换成 JS 模块,让它能够作为 ES6 module 引入呢?

其实很简单,用 JS 将 CSS 的内容,插入到页面即可

```typescript 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 模块引入了。

我们来看看效果:

image-20220704153808985

样式渲染出来了,但又没有完全出来。style-imported.css 的字体颜色样式没有渲染出来。

可以看出有 style-imported.css 的请求是失败的,而看看我们写的 Server,也报错了,错误为找不到文件。

image-20220704154203105

因为没有错误处理,整个 Server 直接崩了,进程退出。

我们来看看是什么原因导致的。

image-20220704154504934

可以看出,使用 style-imported.css 的 src 路径没有了,导致读取文件的时候,读取文件的目录不对,找不到 style-imported.css

为什么直接在 html 中引入 CSS 文件正常,用 JS 引入却会发生问题?

要理解这个,就要理解 CSS 的相对 url 的行为,在 MDN 中的描述如下:

相对地址相对于 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 内部,实际上是使用了方案二;

最终的实现代码如下:

```typescript 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();

}; } ```

效果如下:

image-20220704190136216

可以看到,@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 ,只是把一些能力做出来了,但是毫无架构可言。下篇文章,将会在这个的基础上,逐步地加入一些架构的内容,敬请期待

最后

如果这篇文章对您有所帮助,请帮忙点个赞👍,您的鼓励是我创作路上的最大的动力。

最近注册了一个公众号,刚刚起步,名字叫:Candy 的修仙秘籍,欢迎大家关注~