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

2022-07-18 11:57:29 浏览数 (2)

之前写过几篇 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 文件:

代码语言: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>
</html>

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:

代码语言:javascript复制
{
    "scripts": {
      "dev": "vite",
    },
}

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

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

代码语言:javascript复制
{
    "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 进行调用:

代码语言:javascript复制
// vite.ts
import { createServer } from './src/node/server';
createServer();

然后在 playground 中运行:

代码语言:javascript复制
pnpm run dev
# open http://localhost:3000/

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

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

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

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

中间件机制

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

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

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

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

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

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

实现文件服务中间件

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

代码语言:javascript复制
// /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:

代码语言:javascript复制
import { subModule } from './sub-module.ts';

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

subModule(app!);

sub-module.ts

代码语言:javascript复制
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 函数:

代码语言:javascript复制
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

代码语言:javascript复制
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 的实现如下:

代码语言:javascript复制
// 生成 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 的转换。

代码语言:javascript复制
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 实现如下:

代码语言:javascript复制
const knownJsSrcRE = /.((j|t)sx?)$/;
export const isJSRequest = (url: string): boolean => {
  return knownJsSrcRE.test(url);
};

doTransform 也需要做相应的修改

代码语言:javascript复制
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. 1. 首先先从 CDN 引入 React
代码语言:javascript复制
<!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. 1. 新增 tsx 模块
代码语言:javascript复制
// react-component.tsx
export function ReactComponent(){
  return (
    <div>this is a React Component</div>
  );
}
  1. 1. 引入 tsx 模块
代码语言:javascript复制
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

代码语言:javascript复制
@import "./style-imported.css";
body{
  font-size: 24px;
  font-weight: 700;
}

style-imported.css

代码语言:javascript复制
body{
  color: #2196f3;
}

加入 @import 是为了测试 import style

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

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

最后

如果这篇文章对您有所帮助,请帮忙点个赞

0 人点赞