https://zhuanlan.zhihu.com/p/564104010
- 浏览器不能处理裸模块导入:浏览器只能处理以 /、./ 或 ../ 开头的模块路径,不能直接处理 node_modules 中的模块。
- 依赖预构建的原因:
- CommonJS 和 UMD 兼容性: 在开发阶段中,Vite 的开发服务器将所有代码视为原生 ES 模块。因此,Vite 必须先将以 CommonJS 或 UMD 形式提供的依赖项转换为 ES 模块。
- 性能: 为了提高后续页面的加载性能,Vite将那些具有许多内部模块的 ESM 依赖项转换为单个模块。
有些包将它们的 ES 模块构建为许多单独的文件,彼此导入。例如,lodash-es有超过 600 个内置模块!当我们执行
import { debounce } from 'lodash-es'时,浏览器同时发出 600 多个 HTTP 请求!即使服务器能够轻松处理它们,但大量请求会导致浏览器端的网络拥塞,使页面加载变得明显缓慢。通过将
lodash-es预构建成单个模块,现在我们只需要一个HTTP请求!

实现 hmr:
2024年12月17日 10点40分插件机制的实现其实是用的 koa 的 use 函数;2024年12月17日 16点31分node 下执行 esm 模块规范的 js 文件,要求这个 js 文件在使用相对导入(以 ./ 或 ../ 开头)必须使用完整的文件扩展名(.js、.mjs、.cjs 等),比如import a from "./a.js",而不可以是import a from "./a"
- 这也涉及了 typescript 的 tsconfig.json 中关于
module:nodeNext的知识。
es-module-lexer:
前置知识
vite vs webpack
vite 为什么出现?
koa
我们简单了解下 koa,koa是Express的下一代基于Node.js的web框架。
koa 是对 node.js 的 http 进行了封装,提供了一些便捷的 api 用于开发 web 应用。
首先来看下 koa 的基本使用:
const Koa = require('koa');
// 创建一个Koa实例
const app = new Koa();
// koa的核心概念:middware中间件
app.use(async (ctx) => {
ctx.body = 'Hello World';
});
// 在3000端口监听
app.listen(3000);上边的示例创建了一个 koa 实例,这个实例监听 3000 端口,当监听到请求进来时,会交给 middware 中间件函数进行执行,最终返回"hello world"。

middware “中间件”函数是 koa 的核心,koa 实例会把ctx和next参数传递给它,ctx上下文对象是 koa 对request、response等对象的封装,next是koa传入的将要处理的下一个异步函数。
第二个我们需要了解的middware函数的串联执行:
koa实例可以通过use支持挂载多个 middware 函数,从而实现类似串联执行的效果。借助于 es6 的 async、await函数,koa把很多async函数组成一个处理链,每个async函数都可以做一些自己的事情,然后用await next()来调用下一个async函数。这些middleware可以组合起来,完成很多有用的功能。

koa 会按照middware函数注册的顺序进行执行,所以我们需要合理的分配middware函数的顺序,以及next函数的执行时机来完成需求。
bare import 解决
我们在 js 代码中会通过这种方式来引入 我们安装的包的内容 import Vue from 'vue',这种导入方式称为bare import裸导入,而浏览器是不支持这种方式的。(因为浏览器并不知道去哪里找“vue”)。
所以我们需要去解决这个问题,解决的方式是对 裸导入进行重写,在裸导入的代码中添加一个我们约定好的前缀,
css
vite 支持在 js 中导入 css 文件,比如说像下边这样:
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
createApp(App).mount('#app')但我们知道,js 本身是不支持这种用法的,所以 vite 需要对这种情况进行特殊处理,处理的思路也很简单,把 css 转换成 js 代码,再插入到浏览器中:
// 包装css代码为js
const codeGenCss = (css: string) => {
return `
const insertStyle = (css) => {
let el = document.createElement('style')
el.setAttribute('type', 'text/css')
el.innerHTML = css
document.head.appendChild(el)
}
insertStyle(\`${css}\`)
export default insertStyle
`
}
// middleware
export const cssPlugin = (context: PluginContext) => {
context.app.use(async (ctx, next) => {
// 匹配已css结尾的请求
if (/\.css\??[^.]*$/.test(ctx.path)) {
let cssRes = fs.readFileSync(
path.join(context.basePath, ctx.path),
"utf-8"
);
cssRes = codeGenCss(cssRes)
ctx.type = 'application/javascript'
ctx.body = cssRes
return
}
await next()
})
}重新运行npm run dev,可以看到 style.css 被正确的转换了

同时 css 也被插入到了 html 中了:

scss 等 css 扩展语言
在项目开发中,我们通常会使用 scss、less 等 css 扩展语言,所以也需要支持这些扩展语言的导入。
实现也很简单,我们在前边 cssplugin 基础上添加对scss等后缀判断,并调用相应的扩展语言的编译器进行处理即可。
import sass from 'sass'
export const cssPlugin = (context: PluginContext) => {
context.app.use(async (ctx, next) => {
// ...省略代码
if (/\.scss\??[^.]*$/.test(ctx.path)) {
let scssRes = fs.readFileSync(
path.join(context.basePath, ctx.path),
"utf-8"
);
const result = codeGenCss(
// 使用sass对原始的scss
sass.compileString(scssRes).css.toString()
)
ctx.type = 'application/javascript'
ctx.body = result
return
}
})
}vue
vue 渲染机制
充分了解 vite 如何处理 vue,需要了解 vue 的渲染机制,可以查看官网了解详细信息。
vue 渲染机制是基于“虚拟 dom”这个概念构建的。虚拟 dom本质上就是 js 对象,用于描述要渲染的 dom。
比如说下边这个虚拟 dom 代表了一个 div 元素,可以用 children 属性指定这个节点包含的子节点。
const vnode = {
type: 'div',
props: {
id: 'hello'
},
children: [
/* 更多 vnode */
]
}有了虚拟 dom,我们最终需要把它转换为真实 dom。“渲染器”是 vue 底层用于将“虚拟 dom”转换为真实 dom 的核心部分。
而组件其实就是一种特殊类型的虚拟 dom,比如我们可以使用包含 render 函数的虚拟 dom 来描述一个组件:
const component = {
data:{
},
render(){
}
}而我们编写的 sfc(单文件组件)是 vue 为我们提供的一种方便编写的机制,会被 vue 编译器转换为“虚拟 dom”,被渲染器渲染为真实 dom。
这也是为什么我们通过 cdn 的方式引入 vue 的话,无法使用 sfc 语法书写组件。
sfc 中的 template 部分会被编译成渲染函数,这个渲染函数执行结果会返回“虚拟 dom”。
在这个网站中可以看到 tempalte 编译为渲染函数的结果:

下边这张图就是 组件渲染为真实 dom 的流程:

了解了这些,我们再来梳理下 vite 处理 vue sfc 文件的核心思路就是将 sfc 转换为一个组件选项对象,也就是一个 js 对象,最终交给渲染器来执行。
在 vue 官网的 playground 中也可以看到.vue文件被转换为 js 后的结果:

vue/compile-sfc
@vue/compile-sfc是 vue 官方提供的将 编译 sfc 的包,我们来看下:
我们知道 vue 的 sfc 是由三个部分组成的,template、script、style,@vue/compile-sfc提供了针对这三个部分的 转换 API。

首先需要使用 parse 函数编译 vue sfc 文件,我们来用 app.vue 来看看编译后的结果:

可以看到 parse 函数并没做太多的处理,只是把 sfc 中 template、script、style 三个部分给解析出来。
对于 script部分,需要用到@vue/compiler-sfc的compilerScript函数,这个函数会对<script>部分的代码做一些转换,生成一份最终的组件定义代码,包括:
- 如果使用的是
<script setup>语法糖,将其转换为普通的<script>代码 - 处理各种宏函数的编译: defineProps
我们来看看对 app.vue 中 script 部分转换前后的代码:
转换前:
<script setup>
import HelloWorld from "./components/HelloWorld.vue";
import { ref } from 'vue'
const text = ref('')
</script>转换后:
import HelloWorld from "./components/HelloWorld.vue";
import { ref } from 'vue';
export function setup() {
const text = ref('');
return {
text
};
}
export default { setup };对于 template 部分,需要用到compileTemplate函数;对于 Style 部分,需要用到compileStyle函数。
接下来我们来具体编写 我们的mini-vite中对于.vue 文件的处理。
script 部分
首先添加 vuePlugin.ts,并使用vue/compile-sfc的 parse 函数解析 vue 文件;
import { compileScript, compileStyle, compileTemplate, parse, rewriteDefault } from 'vue/compiler-sfc'
import fs from 'fs'
import path from 'path'
import { parseBareImport } from './modulePlugin.js'
import { hash } from '../utils/index.js'
import type Koa from 'koa'
interface PluginContext {
root: string // 项目根目录
app: Koa
basePath: string // play目录
}
export const vuePlugin = (context: PluginContext) => {
context.app.use(async (ctx, next) => {
// 拦截请求路径中包含 .vue请求
if (/\.vue\??[^.]*$/.test(ctx.path)) {
let source = fs.readFileSync(
path.join(context.basePath, ctx.path),
"utf-8"
);
let { descriptor } = parse(source);
console.log(descriptor)
}
await next()
})
}我们来执行下,看看 descriptor:

接下来我们处理 script 部分:
export const vuePlugin = (context: PluginContext) => {
context.app.use(async (ctx, next) => {
// 拦截请求路径中包含 .vue请求
if (/\.vue\??[^.]*$/.test(ctx.path)) {
let source = fs.readFileSync(
path.join(context.basePath, ctx.path),
"utf-8"
);
let { descriptor } = parse(source);
// compileScript函数需要id参数,用于为每一个组件添加唯一标识以及热更新相关;
let id = descriptor.id = hash(path.resolve(context.basePath, descriptor.filename))
// 处理script部分
let scriptRes = compileScript(descriptor, {
id: descriptor.id,
isProd: false,
})
// 处理裸导入
if (scriptRes) {
let bareJs = await parseBareImport(scriptRes.content)
scriptRes.content = rewriteDefault(bareJs, "__script");
}
}
await next()
})
}注意因为compileScript编译后的 js 还存在裸导入(类似于import {ref} from 'vue'),所以我们添加了裸导入的处理逻辑,并用rewriteDefault重写了默认导出。
最后结果如图:
import HelloWorld from "./components/HelloWorld.vue?import";
import { ref } from '/@module/vue?import'
const __script = {
name: 'App',
components: {
HelloWorld
},
setup() {
const text = ref('vite+vue')
return { text }
}
}template 部分
接下来我们来处理 template 部分,使用compileTemplate函数:
// 拦截请求路径中包含 .vue请求
if (/\.vue\??[^.]*$/.test(ctx.path)) {
// 省略代码
// template部分,把template部分转换为一个render函数
let templateRender
if (descriptor.template) {
templateRender = compileTemplate({
id: descriptor.id,
filename: descriptor.filename,
source: descriptor.template?.content
}).code
// 处理裸导入
templateRender = await parseBareImport(templateRender)
result += `\n${templateRender}`
result += `\n__script.render = render`
}
}App.vue template部分编译后的结果如下:
import { toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, resolveComponent as _resolveComponent, createVNode as _createVNode, Fragment as _Fragment, openBlock as _openBlock, createElementBlock as _createElementBlock } from "/@module/vue?import"
const _hoisted_1 = { class: "title" }
export function render(_ctx, _cache) {
const _component_HelloWorld = _resolveComponent("HelloWorld")
return (_openBlock(), _createElementBlock(_Fragment, null, [
_createElementVNode("div", _hoisted_1, _toDisplayString(_ctx.text), 1 /* TEXT */),
_createVNode(_component_HelloWorld, { msg: "Hello World Component" })
], 64 /* STABLE_FRAGMENT */))
}
__script.render = renderstyle 部分
因为后续需要实现 hmr,所以这里style 部分处理思路是转换为一个单独的 import 请求:
if (/\.vue\??[^.]*$/.test(ctx.path)) {
// 省略代码
// style部分,转换为一个单独的请求
let styleRequest = ctx.path + `?type=style`
result += `\nimport ${JSON.stringify(styleRequest)}`
}最后改写返回的 js 文件默认导出即可。我们来看下完整的 vue sfc 解析代码:
import { PluginContext } from './index.js';
import { compileScript, compileStyle, compileTemplate, parse, rewriteDefault } from 'vue/compiler-sfc'
import fs from 'fs'
import path from 'path'
import { parseBareImport } from './modulePlugin.js'
import { hash } from '../utils/index.js'
export const vuePlugin = (context: PluginContext) => {
context.app.use(async (ctx, next) => {
if (/\.vue\??[^.]*$/.test(ctx.path)) {
let source = fs.readFileSync(
path.join(context.basePath, ctx.path),
"utf-8"
);
let result = ''
let { descriptor } = parse(source);
let id = hash(path.resolve(context.basePath, descriptor.filename))
descriptor.id = id
// js部分
let scriptRes = compileScript(descriptor, {
id: descriptor.id,
isProd: false,
})
if (scriptRes) {
let bareJs = await parseBareImport(scriptRes.content)
scriptRes.content = rewriteDefault(bareJs, "__script");
}
result += scriptRes.content
// template部分,把template部分转换为一个render函数
let templateRender
if (descriptor.template) {
templateRender = compileTemplate({
id: descriptor.id,
filename: descriptor.filename,
source: descriptor.template?.content
}).code
templateRender = await parseBareImport(templateRender)
}
result += `\n${templateRender}`
result += `\n__script.render = render`
// style部分,转换为一个单独的请求
let styleRequest = ctx.path + `?type=style`
result += `\nimport ${JSON.stringify(styleRequest)}`
// 最终把__script作为默认导出
result += `\nexport default __script`
ctx.type = 'application/javascript'
ctx.body = result
}
await next()
})
}最后,npm run dev我们应该就可以看到浏览器中正确渲染了 vue:

环境变量与模式实现
我们在开发项目时,经常会使用环境变量存储不同环境下可变的数据。你是否好奇背后是怎么实现的?
首先我们先看看 vite 中环境变量的基本使用, vite 是在import.meta.env对象上暴露的环境变量,我们在项目根目录下声明的.env.[mode]文件会被加载,加载的环境变量也会通过 import.meta.env 以字符串形式暴露给客户端源码。
比如:
VITE_SOME_KEY=123在代码中可以访问:
console.log(import.meta.env.VITE_SOME_KEY) // "123"那 vite 是如何实现环境变量的呢?我们打开 vite-vue 的在线项目,简单修改下 App.vue 添加环境变量,看下效果:

我们打开控制台,来看下 app.vue 编译后的文件:

可以看到 vite 在代码顶部,添加了赋值语句,将声明的环境变量赋值给 import.meta.env。
好了,可以看出 vite 中环境变量的额实现并不复杂,我们来动手实现。首先我们先来实现如何传递当前模式参数给 vite,vite 才能知道去加载那个环境变量:
我们使用minimist这个库,有了它,我们就可以像这样来在命令行中 传递出参数:
node bin/vite.js --port 3000 --host localhost
我们修改 bin/vite.js:
#!/usr/bin/env node
import { createServer } from '../vite/dist/app.js'
import minimist from 'minimist'
const args = minimist(process.argv.slice(2))
createServer(args)就可以正确的传递参数了;
接下来我们来实现读取环境变量文件.env.[mode]的逻辑: