实现 Vite 核心功能(自测:Vite 核心功能和运行原理有哪些,由最简讲起,具体是怎么实现的)
Webpack 是先打包好文件再放到 dev server 运行
而 Vite 是先运行 dev server ,之后浏览器请求什么文件就在 dev server 中动态编译后再返回。核心是基于浏览器原生支持的 ES Modules (<script type="module">),当浏览器解析到 import 语句时,会向服务器发送 HTTP 请求,服务器拦截这些请求并实时编译文件与响应。
一、搭建服务返回index.html与编译js文件
1.1 搭建基础开发服务器
我们需要一个能拦截请求的 HTTP 服务器。这里使用 Koa (Vite 内部使用 connect,逻辑类似)。
目录结构:
text
mini-vite/
├── src/
│ ├── main.js
│ └── App.vue
├── index.html
├── server.js (我们将编写的代码)
└── package.json
index.html: 关键在于 type="module",这告诉浏览器直接以 ES 模块方式加载 js。
html
<!DOCTYPE html>
<html lang="en">
<body>
<div id="app"></div>
<!-- 浏览器会发起 GET /src/main.js 请求 -->
<script type="module" src="/src/main.js"></script>
</body>
</html>
server.js (第一步:静态文件服务): 浏览器请求 / 返回 HTML,请求 /src/main.js 返回 JS 内容。
js
const Koa = require('koa');
const fs = require('fs');
const path = require('path');
const app = new Koa();
app.use(async (ctx) => {
const url = ctx.request.url;
// 1. 根路径返回 index.html
if (url === '/') {
ctx.type = 'text/html';
ctx.body = fs.readFileSync('./index.html', 'utf-8');
return;
}
// 2. JS文件请求处理 (如 /src/main.js)
if (url.endsWith('.js')) {
const p = path.join(__dirname, url);
ctx.type = 'application/javascript';
ctx.body = fs.readFileSync(p, 'utf-8');
return;
}
});
app.listen(3000, () => {
console.log('Vite dev server running at http://localhost:3000');
});
二、实现第三方库导入处理
2.1 问题描述
在 src/main.js 中,我们通常这样写:
js
import { createApp } from 'vue'; // ❌ 浏览器报错
import App from './App.vue';
浏览器遇到 import ... from 'vue' 时会报错,因为它不知道 'vue' 在哪里。浏览器只认识相对路径 (./, ../) 或绝对路径 (/)。
2.2 解决方案:路径重写
服务器需要在返回 JS 文件内容给浏览器之前 ,把内容里的 'vue' 替换成 '/@modules/vue',给它一个特殊标识。
修改 server.js:
js
// 工具函数:把文件流转成字符串
function readStream(stream) {
return new Promise((resolve, reject) => {
let data = '';
stream.on('data', chunk => data += chunk);
stream.on('end', () => resolve(data));
});
}
// 路径重写逻辑
function rewriteImport(content) {
// 正则匹配: from 'vue' -> from '/@modules/vue'
// s0: 匹配到的完整字符串
// s1: 捕获组,即包名 'vue'
return content.replace(/ from ['"](.*)['"]/g, (s0, s1) => {
// 如果是相对路径 ./ 或 ../ 或 / 开头,不处理
if (s1.startsWith('.') || s1.startsWith('/')) {
return s0;
}
// 否则加上 /@modules/ 前缀
return ` from '/@modules/${s1}'`;
});
}
app.use(async (ctx) => {
const url = ctx.request.url;
if (url.endsWith('.js')) {
const p = path.join(__dirname, url);
const content = fs.readFileSync(p, 'utf-8');
ctx.type = 'application/javascript';
// 返回修改后的内容
ctx.body = rewriteImport(content);
return;
}
});
经过这一步,浏览器收到的代码变成了:
js
import { createApp } from '/@modules/vue'; // ✅ 浏览器会发起新请求
import App from './App.vue';
2.3 获取真实文件路径
当浏览器请求 http://localhost:3000/@modules/vue 时,服务器需要去 node_modules 里找到 vue 的入口文件。
查找步骤:
- 找到
node_modules/vue文件夹。 - 读取
package.json的module字段 (ESM 入口) 或main字段。 - 读取该入口文件的内容返回。
2.4 server.js 新增逻辑
js
app.use(async (ctx) => {
const url = ctx.request.url;
// 3. 处理第三方模块请求
if (url.startsWith('/@modules/')) {
// 提取模块名,例如 'vue'
const moduleName = url.replace('/@modules/', '');
// 在 node_modules 中找到该模块文件夹
const prefix = path.join(__dirname, './node_modules', moduleName);
// 读取 package.json
const packageJSON = require(path.join(prefix, 'package.json'));
// 获取入口文件路径 (优先使用 module 字段,因为是 ESM)
const entryPath = path.join(prefix, packageJSON.module);
// 读取文件内容
const content = fs.readFileSync(entryPath, 'utf-8');
ctx.type = 'application/javascript';
// 第三方库内部可能也引用了其他库,也需要重写路径
ctx.body = rewriteImport(content);
return;
}
// ... 其他逻辑
});
三、处理 .vue 单文件组件 (SFC)
3.1 浏览器不认识 .vue
浏览器请求 App.vue 时,服务器不能直接返回 Vue 源码,需要把 .vue 编译成 JS。
Vite 使用 vue 官方提供的 @vue/compiler-sfc 进行编译。
3.2 server.js 新增 Vue 处理逻辑
js
const compilerSfc = require('@vue/compiler-sfc');
app.use(async (ctx) => {
const url = ctx.request.url;
// 4. 处理 .vue 文件
if (ctx.url.endsWith(".vue")) {
ctx.type = "application/javascript; utf-8";
const content = fs.readFileSync(path.join(__dirname, ctx.url), "utf-8");
const { descriptor } = compilerSfc.parse(content);
// 使用 inlineTemplate 选项,让 compileScript 直接生成包含 render 的完整组件
const compiled = compilerSfc.compileScript(descriptor, {
id: ctx.url,
inlineTemplate: true, // 关键:内联编译模板,setup 直接返回 render 函数
});
ctx.body = rewriteImport(compiled.content);
return;
}
});
四、Vite 核心功能总结
实现一个简易 Vite 只需要解决三个问题:
- 服务器:用 Koa 拦截浏览器发起的文件获取 HTTP 请求并实时编译与返回。
- JS 处理 :遇到
import 'vue'这种裸模块导入,重写路径为/@modules/vue,并去node_modules里找文件返回。 - Vue 处理 :遇到
.vue文件,使用compiler-sfc编译。先把 Script 发给浏览器,再让浏览器回头取 Template 的编译结果,最后拼在一起。
这种模式下,开发环境启动速度与项目大小无关,因为只有当你点击了某个页面,浏览器发起了请求,服务器才开始编译那个页面用到的文件。
Vite HMR实现原理(自测:更新一个文件后,wbp和vite分别会经过什么流程进行网页的热更新)
一、先回顾 Webpack 热更新原理
假如你的项目有1000个JS模块,你修改了其中一个文件 src/components/Header.vue。
Webpack的处理方式
- Wepack Compiler 监听工作区:Webpack监听到文件保存动作。
- 重新构建被修改的模块:loader 链转换文件为 JS 可执行代码 -> AST 解析代码并识别 import、export 代码进行依赖图的增加或删除 -> 对新发现的依赖进行递归处理
- 打包:生成 Manifest JSON 文件,告诉浏览器这次更新涉及哪些模块;生成 Update Chunk JS 文件,包含被修改那个模块的新代码。
- 推送:HMR Server通过WebSocket推送更新通知给浏览器。
- 替换:浏览器的 HMR Runtime 请求清单文件,并根据清单文件请求被更新的模块代码,接着找到模块是否有自己的 module.hot.accept ,否则冒泡沿着依赖图向上查找,在 accept 回调中 import 并执行新的JS代码进行视图的更新。
三、Vite HMR 具体工作流程
1. 建立连接
客户端(浏览器)连接 Vite 开发服务器的 WebSocket。
2. 文件修改与通知
当你保存 Header.vue 时:
- Vite 文件监听器检测到变化。
- 解析该文件导出内容,确定它是Vue组件。
- 通过 WebSocket 向客户端发送一段JSON消息。
消息内容示例:
json
{
"type": "update",
"updates": [
{
"type": "js-update",
"timestamp": 1678888888,
"path": "/src/components/Header.vue",
"acceptedPath": "/src/components/Header.vue"
}
]
}
3. 浏览器重新请求
Vite 在浏览器端注入的客户端代码(vite/client)收到消息。它不会像 Webpack 那样去执行一段新推过来的 JS 代码块,而是利用浏览器动态导入功能。
具体操作: 浏览器构造一个新的 import URL,带上时间戳以强制让浏览器认为这是一个新文件,从而避开缓存。
js
// 浏览器端逻辑模拟
import('/src/components/Header.vue?t=1678888888')
.then((newModule) => {
// 获取到新的模块内容,进行替换
});
4. 模块替换
对于Vue组件,Vite使用了 vue-loader 类似的逻辑(vite-plugin-vue)。
- 旧的
Header.vue组件实例还保留在内存中。 - 新的模块加载后,框架(Vue/React)利用
HMR API重新渲染该组件,保留组件内的 data/state 状态,仅更新 render 函数或样式。
四、Vite HMR API:import.meta.hot
Webpack使用 module.hot,而 Vite 使用 ESM 标准的 import.meta.hot。
开发者的代码(通常由插件自动注入):
js
// src/components/Header.vue 编译后的JS代码
// ... 组件代码 ...
export default _sfc_main;
// HMR 逻辑
if (import.meta.hot) {
// 接受自身更新
import.meta.hot.accept((newModule) => {
if (newModule) {
// 执行组件重渲染逻辑
__VUE_HMR_RUNTIME__.reload('组件HashID', newModule.default);
}
});
}
实现逻辑:
import.meta.hot.accept:告诉 Vite,如果这个文件变了,不需要刷新页面,我自己能处理。- 回调函数 :当新文件被
import(...)加载成功后,执行这个回调,传入新模块内容。
五、所以为什么 Vite HMR 速度快
- 无需重构依赖图:文件保存后无需重新分析依赖图的更改,本质是因为 Vite 不需要构建依赖图去生成 bundle,而是通过浏览器 ESM 能力提供所需文件即可。
- 无需打包:Vite 只需编译一次文件,而 Webpack 需要将受影响的模块及其相关依赖(修改模块本身、父节点可能更新对子模块的Module ID引用代码、所属Chunk)重新打包与合并,涉及 n 个文件的修改。
- 全量代码下发:Webpack 下发包含新代码的 HMR 更新包,而 Vite 只发送一个指向修改该文件的 HTTP 请求,由浏览器重新请求。
Vite Plugin 实现原理与实战(自测:实现一个vite-plugin-svg-icons)
在前文中,我们了解了 Webpack 的打包流程:读取入口 -> 分析 AST -> 递归依赖 -> 转换代码 -> 生成 Bundle。
Vite 的工作方式完全不同。在开发环境下,Vite 不打包 。它利用浏览器对 ES Modules 的原生支持。当浏览器发起请求(如 GET /src/main.js)时,Vite 服务器拦截请求,进行必要的代码转换,然后直接返回 JS 内容。
Vite 插件 就是用来拦截 和处理这些请求的工具。
Vite 插件基于 Rollup 的插件接口设计,同时扩展了一些 Vite 独有的钩子(Hooks)。
一、Vite 插件的核心钩子 (Hooks)
Webpack 将功能分为 Loader(转换文件)和 Plugin(监听构建生命周期)。Vite 将这两者合并了。一个 Vite 插件本质上是一个返回配置对象的函数。
处理一个文件请求时,主要经过以下三个核心钩子:
- resolveId(source, importer) : 找文件
- 输入 : 代码中的导入路径(如
import x from './a'中的'./a')。 - 作用: 告诉 Vite 这个文件的绝对路径在哪里,或者标记这是一个"虚拟模块"。
- 返回: 文件的绝对路径或 ID。
- 输入 : 代码中的导入路径(如
- load(id) : 读文件
- 输入 :
resolveId返回的绝对路径或 ID。 - 作用: 读取文件内容。通常用于加载磁盘文件或生成虚拟文件内容。
- 返回: 文件内容的字符串。
- 输入 :
- transform(code, id) : 改代码 (相当于 Webpack Loader)
- 输入 :
load返回的代码字符串,以及文件 ID。 - 作用: 将非 JS 代码(如 Vue, CSS, TS)转换为浏览器能识别的 JS 代码。
- 返回: 转换后的 JS 代码。
- 输入 :
二、实战:实现一个虚拟模块插件
场景:你需要在一个项目中引入一个并不存在于磁盘上的文件,比如构建时的环境变量信息。
目标代码:
javascript
// main.js
import env from 'virtual:env'; // 这个文件在磁盘上不存在
console.log(env);
插件实现:
javascript
export default function myVirtualPlugin() {
const virtualModuleId = 'virtual:env';
const resolvedVirtualModuleId = '\0' + virtualModuleId; // \0 是 Rollup 的约定,表示这是一个虚拟模块,不要去磁盘找
return {
name: 'my-virtual-plugin', // 插件名称,必填
// 1. 拦截 import
resolveId(source) {
if (source === virtualModuleId) {
// 如果 import 的是 'virtual:env',返回我们自定义的 ID
return resolvedVirtualModuleId;
}
return null; // 其他文件不管,交给 Vite 处理
},
// 2. 加载内容
load(id) {
if (id === resolvedVirtualModuleId) {
// 匹配到自定义 ID,直接返回一段 JS 代码
return `export default {
user: "admin",
buildTime: "${new Date().toISOString()}"
}`;
}
return null; // 其他文件不管,读取磁盘
}
};
}
配置 vite.config.js:
javascript
import myVirtualPlugin from './plugins/myVirtualPlugin';
export default {
plugins: [myVirtualPlugin()]
};
三、实战:实现一个 vite-plugin-svg-icons
首先明确插件功能:扫描 指定目录下的 SVG 文件 -> 转换 为 <symbol> 标签并合并 -> 提供 虚拟模块 virtual:svg-register import 注入页面 -> 支持 HMR 热更新。
为了更好理解插件功能,我们看看在实际场景中它的作用:
你正在开发一个企业级后台管理系统,设计师提供了一套自定义 SVG 图标(如 nav-order.svg, action-edit.svg),要求图标颜色能随文字颜色变化(如菜单 Hover 时变蓝),且会有数十个图标散落在各个页面。
使用img标签,第一个是无法改变颜色需要重新提供另一版本svg,并且还需要根据hover事件动态切换src,非常麻烦;使用内联svg代码,代码很臃肿,可读性差;使用手动import,若一个页面需要的svg很多,会产生大量import语句
我们的插件目标:
- 零配置引用 :只需将 SVG 文件丢入
src/icons文件夹,无需任何import语句,直接通过文件名即可使用。 - CSS 样式控制 :插件生成的 SVG Sprite 支持
currentColor,图标就像文字一样,可以用 CSS 随意控制颜色 和大小。 - 高性能 :所有图标被合并成一段 JS 注入 HTML,零 HTTP 请求,且按需加载。
使用效果演示:
js
// main.ts
import 'virtual:svg-register' // 一行代码,所有图标自动打包注入
html
<!-- 无需 import,直接使用 -->
<svg class="icon" aria-hidden="true">
<use xlink:href="#icon-nav-order" />
</svg>
<style>
.icon {
color: grey; /* 默认灰色 */
font-size: 20px; /* 控制大小 */
}
.icon:hover {
color: blue; /* 悬停自动变蓝,无需 JS */
}
</style>
可以封装为一个组件:
html
<!-- src/components/SvgIcon.vue -->
<template>
<svg class="svg-icon" aria-hidden="true">
<use :xlink:href="symbolId" />
</svg>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps({
name: { type: String, required: true }, // 传入图标文件名,如 'truck'
prefix: { type: String, default: 'icon' }
})
const symbolId = computed(() => `#${props.prefix}-${props.name}`)
</script>
<style scoped>
.svg-icon {
width: 1em; height: 1em; /* 默认跟随字体大小 */
vertical-align: -0.15em;
fill: currentColor; /* 关键:让图标颜色跟随文字颜色 */
overflow: hidden;
}
</style>
现在我们来实现这个插件功能
首先理解"虚拟模块"
你可以在浏览器端 import 一个不存在于文件系统中的文件。
目标:用户在代码里写 import 'virtual:svg-register',插件可以正确识别和拦截。
具体实现:使用 resolvedId 属性进行配置,当文件路径是我们的虚拟模块时,直接返回不需要解析,并且在 load 阶段返回我们自定义的代码交给程序执行
第二步:实战代码编写
新建一个 my-svg-plugin.js 。
我们可以安装一个依赖来方便找文件:npm install fast-glob。
javascript
// my-svg-plugin.js
import path from 'path'
import fs from 'fs'
import fg from 'fast-glob'
export default function mySvgPlugin(options) {
// 1. 配置虚拟模块 ID
const VIRTUAL_MODULE_ID = 'virtual:svg-register'
const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID
return {
name: 'vite-plugin-my-svg-sprite', // 插件名称
// 2. resolveId: 告诉 Vite 这个 import 归我管
resolveId(id) {
if (id === VIRTUAL_MODULE_ID) {
return RESOLVED_VIRTUAL_MODULE_ID
}
},
// 3. load: 返回这个虚拟模块的具体代码
async load(id) {
if (id === RESOLVED_VIRTUAL_MODULE_ID) {
// --- 核心逻辑开始 ---
// A. 找到所有 SVG 文件
const { iconDir } = options
const svgFiles = await fg('**/*.svg', { cwd: iconDir, absolute: true })
// B. 遍历并读取内容,拼接成 Symbol 字符串
let symbols = ''
svgFiles.forEach((file) => {
if (file.endsWith(".svg")) {
const content = fs.readFileSync(path.join(iconDir, file), "utf-8");
const viewBox = content.match(/viewBox="([^"]+)"/)?.[1] || "0 0 24 24";
const pathContent = content.match(/<svg[^>]*>(.*)<\/svg>/s)?.[1] || "";
const iconName = file.replace(".svg", "");
symbols += `<symbol id="icon-${iconName}" viewBox="${viewBox}">${pathContent}</symbol>`;
}
});
// C. 构造最终的 JS 代码
// 返回在页面中注入 SVG sprite 的代码
return `
const svgSprite = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svgSprite.style.position = 'absolute';
svgSprite.style.width = '0';
svgSprite.style.height = '0';
svgSprite.innerHTML = \`${symbols}\`;
document.body.insertBefore(svgSprite, document.body.firstChild);
`;
// --- 核心逻辑结束 ---
}
}
}
}
第三步:在项目中使用 (验证效果)
-
配置
vite.config.js:javascriptimport { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import path from 'path' import mySvgPlugin from './my-svg-plugin' // 引入你写的插件 export default defineConfig({ plugins: [ vue(), mySvgPlugin({ iconDir: path.resolve(__dirname, 'src/icons') // 假设你的图标都在这里 }) ] }) -
准备素材 : 在
src/icons下放几个 svg 文件,比如vue.svg,react.svg。 -
引入注册 : 在
src/main.js(或main.ts) 中引入虚拟模块:javascriptimport { createApp } from 'vue' import App from './App.vue' // 这一行会触发你插件的 resolveId -> load, // 然后在浏览器执行那段插入 DOM 的 JS 代码 import 'virtual:svg-register' createApp(App).mount('#app') -
组件使用: 在 Vue 组件里写:
html<template> <div> <!-- 使用图标 --> <svg style="width: 50px; height: 50px; fill: red;"> <use xlink:href="#icon-vue"></use> </svg> <svg style="width: 50px; height: 50px; fill: blue;"> <use xlink:href="#icon-react"></use> </svg> </div> </template>
四、Vite 独有的钩子:configureServer
Vite 插件不仅仅是构建工具,还是一个开发服务器。configureServer 钩子允许我们在 Vite 的 Node.js 服务器(基于 connect 库)中添加中间件。这在 Webpack Plugin 中很难直接做到。
场景 :实现一个简易的 API Mock 功能。当请求 /api/user 时,拦截请求并返回假数据,不经过后端。
插件实现:
javascript
export default function myMockPlugin() {
return {
name: 'my-mock-plugin',
configureServer(server) {
// server 是 Vite 开发服务器实例
// server.middlewares 是一个 connect 实例,用法类似 Express
server.middlewares.use((req, res, next) => {
// 拦截 /api/user 请求
if (req.url === '/api/user') {
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ id: 1, name: 'Mock User' }));
return; // 结束请求
}
// 其他请求放行
next();
});
}
};
}
五、Vite 的热更新 (HMR) 钩子:handleHotUpdate
在 Webpack 中实现 HMR 需要修改打包逻辑。在 Vite 中,插件可以直接介入 HMR 流程。
场景 :当 .txt 文件修改时,不刷新页面,只通过自定义事件通知浏览器更新。
插件实现 (服务端):
javascript
export default function myHmrPlugin() {
return {
name: 'my-hmr-plugin',
handleHotUpdate({ file, server, modules }) {
if (file.endsWith('.txt')) {
// 1. 读取更新后的文件内容
const content = require('fs').readFileSync(file, 'utf-8');
// 2. 向浏览器发送自定义 Websocket 消息
server.ws.send({
type: 'custom',
event: 'txt-update',
data: { file, content } // 发送新内容
});
// 3. 返回空数组,告诉 Vite:这个文件我处理了,你不需要执行默认的 HMR 逻辑(默认逻辑通常是重新加载模块)
return [];
}
}
};
}
客户端代码 (Client):
javascript
// 在 main.js 中接收消息
if (import.meta.hot) {
import.meta.hot.on('txt-update', (data) => {
console.log(`文件 ${data.file} 变了,新内容是: ${data.content}`);
// 在这里手动更新 DOM
document.querySelector('#app').innerText = data.content;
});
}
六、总结:Webpack vs Vite 插件开发对比
| 功能点 | Webpack 实现方式 | Vite 实现方式 |
|---|---|---|
| 引入非 JS 文件 | Loader (如 css-loader) |
Plugin 的 transform 钩子 |
| 寻找模块路径 | resolve.alias 配置或 Resolver 插件 |
Plugin 的 resolveId 钩子 |
| 读取文件内容 | Loader 读取 | Plugin 的 load 钩子 |
| 开发服务器拦截 | devServer.before 配置 |
Plugin 的 configureServer 钩子 |
| 热更新控制 | 注入 Runtime 代码,较复杂 | Plugin 的 handleHotUpdate + import.meta.hot |
开发思维转变:
- Webpack 插件像是在一条已经铺好的流水线(Compiler Hooks)上安装传感器和机械臂。
- Vite 插件更像是拦截器。浏览器请求文件 -> 你的插件拦截 -> 告诉你 ID -> 你给它内容 -> 你转换内容 -> 返回给浏览器。