Vite Server 是如何处理页面资源的?
我们知道,Vite 在开发环境下,会打开一个 Dev Server 用于预览开发的页面,那么这个 Dev Server 到底做了什么呢?它是怎么做到将我们的代码展示成页面的,接下来我们就来一探究竟。
构造项目
我们构造一个最简单的项目,项目中没有用到 npm 包、css 等功能,就只有一个 index.html
和一个 typescript
文件。
目的:剥离出复杂的内容,用最简单的例子去说明最核心的内容
代码放在该GitHub 仓库链接[1]
├─ index.html
├─ index.ts
index.html 代码如下:
<!DOCTYPE html>
<html lang="en">
<head>
</head>
<body>
<div id="app"></div>
</body>
<script type="module" src="./index.ts"></script>
</html>
index.ts 代码如下:
const app = document.getElementById('app');
app!.innerHTML = 'helloworld';
项目有了,接下来我们从用户侧,看看 Vite Server 做了什么?
用户侧视觉
在项目目录,运行 vite 命令,我们会看到如下输入:
vite v3.0.0-alpha.0 dev server running at:
> Local: http://localhost:5173/
> Network: use `--host` to expose
ready in 551ms.
可以看到 vite 创建了一个 dev server,用于访问页面。
访问页面,页面展示出 helloworld,请求如下:
• 拉取
index.html
• Vite 的热更新相关脚本:
/@vite/client
•
/client/env.mjs
•
ws://localhost:5173/
• 我们写的 ts 代码:
/index.ts
为什么我们明明只写了
index.html
和index.ts
,但这里却还会有其他的资源请求?
我们查看 index.html
的代码:
<!DOCTYPE html>
<html lang="en">
<head>
+ <script type="module" src="/@vite/client"></script>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div id="app"></div>
</body>
<script type="module" src="./index.ts"></script>
</html>
这里可以看出,index.html
已经被修改了,插入了一段名为 client 代码,这段代码其实是用于 Vite 热更新的,它开启了一个 websocket。client 还依赖了其他脚本,因此浏览器还会继续发起请求,所以会看到有多个请求。
再看看 index.ts
:
const app = document.getElementById("app");
- app!.innerHTML = 'helloworld';
+ app.innerHTML = "helloworld";
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbIkQ6L3RlbmNlbnQvYXBwL3doYXQtdml0ZS1kby9wYWNrYWdlcy9zaW1wbGUvaW5kZXgudHMiXSwic291cmNlc0NvbnRlbnQiOlsiY29uc3QgYXBwID0gZG9jdW1lbnQuZ2V0RWxlbWVudEJ5SWQoJ2FwcCcpO1xuYXBwIS5pbm5lckhUTUwgPSAnaGVsbG93b3JsZCc7XG4iXSwibWFwcGluZ3MiOiJBQUFBLE1BQU0sTUFBTSxTQUFTLGVBQWUsS0FBSztBQUN6QyxJQUFLLFlBQVk7IiwibmFtZXMiOltdfQ==
index.ts
的代码已经被编译成 js 了,并且拼接上了 sourcemap。
浏览器是不能运行 ts 代码的,为什么浏览器能运行 index.ts?
其实浏览器要怎么处理一个请求,是看它的响应 Header 中的 Content-Type 的
这个与文件后缀是无关的,在我们实际开发中,很多请求是 ts、tsx、vue,但无论什么后缀都是没有关系的,它们的 Content-Type 都是 application/javascript
,因此浏览器能够正确的运行处理。
到目前为止,用户侧所看到的 Vite Server 的行为,已经明确了:
• 修改
index.html
,在 head 标签中加入了 client 脚本。• 编译
index.ts
,并拼接上 sourcemap。• 连接 websocket
为了简单起见,我们本篇文章不讲述热更新的内容,如果感兴趣,可以查看《Vite 热更新的主要流程》[2],该文章同样是用了最简单的例子,讲述 Vite 热更新的核心流程,建议阅读。
Server 的中间件机制
我们从用户侧可以看出,Vite Server 对不同的请求的文件做了特殊的处理,然后进行响应返回给客户端
那一个 Server 要如何处理请求的呢?答案是,使用中间件
中间件机制
Vite 用 connect[3] 包来创建一个 DevServer。其简单的用法如下:
var connect = require('connect');
var http = require('http');
var app = connect();
// 使用一个中间件
app.use(function(req, res){
res.end('Hello from Connect!\n');
});
// 创建 nodejs http server,并监听 3000 端口
http.createServer(app).listen(3000);
connect
的中间件机制,可以用如下图表示:
当一个请求发送到 server 时,会经过一个个的中间件,中间件本质是一个回调函数,每次请求都会执行回调。
connect
的中间件机制有如下特点:
• 每个中间件可以分别对请求进行处理,并进行响应。
• 每个中间件可以只处理特定的事情,其他事情交给其他中间件处理
• 可以调用 next 函数,将请求传递给下一个中间件。如果不调用,则之后的中间件都不会被执行
由于 html
和 TS
文件的处理方式完全不同,因此要做成两个不同的中间件。
•
html
处理中间件• 代码转化中间件
html 处理中间件
中间件的部分代码实现如下:
async function viteIndexHtmlMiddleware(req, res, next) {
// 去掉 url 中的 hash 和 query
const url = req.url && cleanUrl(req.url)
// 只处理 html 的请求,否则调用 next 传递请求给下个中间件
if (url?.endsWith('.html')) {
// 从 url 中获取 html 文件路径
const filename = getHtmlFilename(url, server)
if (fs.existsSync(filename)) {
try {
// 读取文件,拿到 html 的代码字符串
let html = fs.readFileSync(filename, 'utf-8')
// 转换 html 代码,返回转换后的代码字符串
html = await server.transformIndexHtml(url, html, req.originalUrl)
// 响应请求
return send(req, res, html, 'html', {
headers: server.config.server.headers
})
} catch (e) {
return next(e)
}
}
}
next()
}
该中间件只处理 html 请求。如果不是 html 请求,就直接调用 next,将请求交给后续的中间件处理了。
中间件核心流程就是:
• 读取 html 文件
• 执行 transform 转换/修改内容
• 响应请求
我们从用户侧视觉中,也可以看出,transform
就是加上了让的热更新代码,但要是认为它只有这个作用,那就小看 Vite 啦!
Vite 有非常高的可扩展性,加上热更新代码,只不过是 Vite 一个小小的内部插件实现的功能。
我们来看看 Vite 的 transformIndexHtml
[4] 插件钩子,它可以对 index.html
进行修改,可以插入任何的内容。
通过在 transformIndexHtml
[5] 钩子中,直接修改 html
代码,或者设置 transformIndexHtml
钩子的返回值的方式,对 html
插入内容。
根据 hook 的返回值,做不同的处理,返回结果的类型如下:
type IndexHtmlTransformResult =
| string
| HtmlTagDescriptor[]
| {
html: string
tags: HtmlTagDescriptor[]
}
interface HtmlTagDescriptor {
tag: string
attrs?: Record<string, string>
children?: string | HtmlTagDescriptor[]
/**
* 默认: 'head-prepend'
*/
injectTo?: 'head' | 'body' | 'head-prepend' | 'body-prepend'
}
可以看出,返回结果,可以是 string、数组、对象
• 字符串 —— 则直接替换成转换后
html
代码• 对象和数组 —— 需要注入
html
标签,通过HtmlTagDescriptor
进行配置
HtmlTagDescriptor
的配置内容分为两类:
• 注入内容
• 注入的位置
配置方式如下图:
{
tag: 'script',
attrs: {
type: 'module',
src: '/@vite/client'
},
injectTo: 'head-prepend'
}
就是在 <head>
标签内的最前面,拼接上 <script src="/@vite/client" type="module"></script>
代码转换中间件
transformMiddleware
中间件的实现如下:
async function viteTransformMiddleware(req, res, next) {
// 只处理 GET 请求,其他不处理
if (req.method !== 'GET') {
return next()
}
const url: string = req.url
// 只处理部分的请求
if (
// 用正则表达式判断:/\.((j|t)sx?|mjs|vue|marko|svelte|astro)($|\?)/
// ts、vue 都算作是 js 请求
isJSRequest(url)
) {
const result = await transformRequest(url, server, {
html: req.headers.accept?.includes('text/html')
})
if (result) {
return send(req, res, result.code)
}
}
next()
}
可以发现,其实中间件的大致框架/写法,都是差不多的,只处理部分请求,其他的调用 next 函数,将请求交给下一个中间件处理。
TS/JS
的 transform
就复杂一点了,因为这里其实不仅仅要处理 TS、JS,其实还可能要处理 Vue、TSX 等组件代码,那 Vite 是怎么实现的呢?
答案是:使用 Vite 插件去扩展这些转换、编译代码的能力。
框架是越来越多的,Vite 不可能把这些框架的后缀都内置到 Vite 中,这时候就需要插件提供的扩展能力了,这又是 Vite 扩展性的一大体现。
我们来看看一个文件模块到底经历了哪些的处理过程?
•
resolveId
,输出是一个本地的实际的路径,npm 包则会指向 node_modules 中的实际位置。•
load
,输出是文件模块的代码字符串,默认就是直接读取文件内容并返回。•
transform
,对代码进行转换。默认行为是不处理。
三个流程分别对应了三个插件钩子:resolveId
、load
、transform
[6],这三个钩子,在开发环境中,由 Vite 提供,在生产环境打包时,则由 Rollup 提供。
模块的处理代码如下(有删减):
async function doTransform(
url: string,
server: ViteDevServer,
options: TransformOptions,
timestamp: number
) {
// 存放代码字符串
let code: string | null = null
// 存放 sourcemap
let map: SourceDescription['map'] = null
// 解析出本地的实际路径
const id = (await pluginContainer.resolveId(url))?.id || url
// 加载出模块的代码字符串
const loadResult = await pluginContainer.load(id, { ssr })
code = loadResult.code
// 转换代码
const transformResult = await pluginContainer.transform(code, id, {
inMap: map,
ssr
})
code = transformResult.code
map = transformResult.map
return {
code,
map,
}
}
我在 《Vite 是如何兼容 Rollup 插件生态的》[7]中详细介绍过 PluginContainer
的作用,感兴趣的可以看一下,这里大概总结一下:
PluginContainer
的作用是在 Vite 中模拟 Rollup 的插件机制,它在内部实现 Rollup 的钩子,pluginContainer.load
实际上会调用的所有 Vite 插件的 load
钩子。
我们用户侧看到的 index.ts
插件被转换,也是 Vite 的内置插件,用 transform
钩子进行编译转换的。实际上 Vite 是使用了 esbuild,对单个文件进行转译:
export function esbuildPlugin(options: ESBuildOptions = {}): Plugin {
const filter = createFilter(
options.include || /\.(tsx?|jsx)$/,
options.exclude || /\.js$/
)
return {
name: 'vite:esbuild',
async transform(code, id) {
// 只处理 ts/tsx/jsx,不处理 js
if (filter(id)) {
const result = await transformWithEsbuild(code, id, options)
return {
code: result.code,
map: result.map
}
}
}
}
}
transformWithEsbuild
函数,则是使用 esbuild
对代码进行转译。
经过转译之后,就是我们用户侧看到的 js
代码了。
总结
本篇文章首先构造出一个最简单的项目,这样便于只关注 Vite 的核心流程;然后简单地介绍了 Connect 的中间件机制,以及说明,Vite Server 的请求处理能力,是通过中间件实现的;然后我们分别介绍了 html
处理插件和 TS
处理中间件。
•
html
处理中间件,通过调用插件的transformIndexHtml
对html
页面进行处理。•
TS
处理中间件,通过调用插件的resolveId
、load
、transform
这三个钩子,对代码进行处理的
从中我们也可以看出,Vite 通过插件,实现了非常高的可扩展性。
处理过后的代码,会作为请求的响应值,返回到浏览器,浏览器会根据 Content-type
对响应内容,进行相应的处理。经过这些步骤,一个简单的页面就能够展示出来了。
可以看出,Vite 的核心流程其实非常简单,当然本篇文章,有很多内容其实也是没有说到的,Vite 内部有很多内置的中间件、插件没有介绍,同时 Vite 有很多内部逻辑,也是被忽略的,例如配置的解析、依赖预构建、缓存、优化等等,但其实也不影响我们做出一个简单版本的 Vite。
本篇文章,主要从概念上说明 Vite Server 的行为,下篇文章,我会手写一个简单的 Vite Server,并用它来跑我们这次构造的简单项目,敬请期待~
如果这篇文章对您有所帮助,请帮忙点个赞👍,您的鼓励是我创作路上的最大的动力。
引用链接
[1]
链接: https://github.com/candy-Tong/what-vite-do/tree/main/packages/simple[2]
《Vite 热更新的主要流程》: https://juejin.cn/post/7096103959563075597[3]
connect: https://www.npmjs.com/package/connect[4]
transformIndexHtml
: https://cn.vitejs.dev/guide/api-plugin.html#transformindexhtml[5]
transformIndexHtml
: https://cn.vitejs.dev/guide/api-plugin.html#transformindexhtml[6]
resolveId
、load
、transform
: https://cn.vitejs.dev/guide/api-plugin.html#universal-hooks[7]
《Vite 是如何兼容 Rollup 插件生态的》: https://juejin.cn/post/7109437324047417357
往期推荐
最后
欢迎加我微信,拉你进技术群,长期交流学习...
欢迎关注「前端Q」,认真学前端,做个专业的技术人...