之前写过几篇 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> <html lang="en"> <head> <meta charset="UTF-8"> <title>Titletitle> 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 += ' 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
这个包,它是一个非常轻量级中间件,用于处理对静态资源的请求。
// /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); }; }
使用中间件的方式:
// vite.ts app.use(staticMiddleware());
然后重新执行 vite.ts
,**重启 Server **(由于我们的 Server 没有做热更新机制,每次修改必须手动重启 Server,代码才会生效),访问 http://localhost:3000
,就能显示出页面了。
这其实就是个平平无奇的文件服务,根据请求的访问路径,读取文件。因为浏览器能直接执行 js 的代码,因此能正确展示页面。
如果我们把 JS 改成 TS。
Title - + 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 += ' this is a subModule'; }
这下子页面就出不来了:
因为浏览器无法识别 TS 的语法,自然就报错了。当然这是预期之内的。因为 vite 会在请求中对 TS 进行编译,而我们这里并没有处理。那我们接下来把这个能力补上。
TS 编译中间件
先来写一个中间件的基本结构:
// /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
- 把
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
实现如下:
typescript
复制代码
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
- 首先先从 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>
- 新增 tsx 模块
// react-component.tsx export function ReactComponent(){ return ( <div>this is a React Component</div> ); }
- 引入 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 中引入,并使这种引入方式能够正常生效。
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,是不行的。会得到以下错误:
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 中间件
同样的,我们先写一个中间件的基本结构:
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(); }; }
接下来补充一下,文件的读取:
const file = url.startsWith('/') ? '.' + url : url; const rawCode = await readFile(file, 'utf-8');
那如何将 CSS 转换成 JS 模块,让它能够作为 ES6 module 引入呢?
其实很简单,用 JS 将 CSS 的内容,插入到页面即可
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 中的描述如下:
相对地址相对于 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 内部,实际上是使用了方案二;
最终的实现代码如下:
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
,只是把一些能力做出来了,但是毫无架构可言。下篇文章,将会在这个的基础上,逐步地加入一些架构的内容,敬请期待。
最后
如果这篇文章对您有所帮助,请帮忙点个赞👍,您的鼓励是我创作路上的最大的动力。
最近注册了一个公众号,刚刚起步,名字叫:Candy 的修仙秘籍,欢迎大家关注~