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 = render
style 部分
因为后续需要实现 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]
的逻辑: