Vite
概览
Vite 基于 ES Module,因为浏览器原生支持 ES Module。利用浏览器去解析 imports,在服务器端按需编译返回,跳过打包过程。生产环境使用 Rollup
打包,开发环境使用 esbuild
预构建依赖,将浏览器不支持的格式转换为浏览器支持的语法,无需打包。
esbuild 使用 Go 编写,比以 JS 编写的打包器预构建依赖快 10 ~ 100 倍。
还有一个正在进行中的工作,即构建一个名为 Rolldown
的 Rust 版本的 Rollup。一旦 Rolldown 准备就绪,它就可以在 Vite 中取代 Rollup 和 esbuild,显著提高构建性能,并消除开发和构建之间的不一致性。
为什么 Vite 在开发环境不需要打包,在生产环境需要打包呢?
- 浏览器兼容性:生产环境需要处理旧浏览器不支持的 ES Module 语法,如
import.meta
- 性能优化:合并代码、压缩、Tree-Shaking 等
创建项目
npm create vite@latest
# 或者
pnpm create vite
在搭建 Vite 项目时,执行npm create vite@latest
命令,这里的 create vite
只是内置了 Vite 的脚手架,并不是 Vite 本身,通过脚手架可以快速创建一个 Vite 项目。
Vite 为什么比 Webpack 快?
痛点:随着项目越来越大,webpack 需要很长时间才能启动开发服务器,即使使用 HMR,文件修改后的效果也需要几秒钟才能在浏览器中反映出来。迟钝的反馈会影响开发效率。
webpack 支持多种模块化规范,一开始就必须要统一模块化代码,这意味着需要将所有的依赖全部读一遍。处理后得到一个或多个 Bundle 文件,然后启动开发服务器。而 Vite 是一开始就启动开发服务器,在浏览器请求源码时进行转换(如转换 jsx、ts、less、vue 组件等)并按需提供源码,根据情景动态导入代码。
- Vite 启动流程:启动服务器 -> 按需编译首个页面 -> 其他模块等请求时再处理
- Webpack 启动流程:扫描所有模块 -> 打包成 Bundle -> 启动开发服务器
Vite 利用了浏览器原生支持的 ES module。只要在 script 标签上添加 type="module"
标记, main.js 就可以直接使用 import 语法去动态导入 js 文件。
总结:
区别 | Vite | Webpack |
---|---|---|
工作原理 | 1、基于浏览器 ES 模块的开发模式,直接利用浏览器加载模块。 2、开发模式下使用原生 ES 模块,按需加载文件,无需打包。 3、生产模式下使用 Rollup 进行打包,生成优化的静 态文件。 | 1、采用打包机制,将所有模块打包成一个或多个 bundle 文件。 2、开发模式下使用 webpack-dev-server 提供服务,通过热更新(HMR)实现快速开发。 3、构建时需要遍历所有依赖,生成依赖图,然后进行打包。 |
开发体验 | 1、启动速度极快,无需打包,按需加载文件。 2、热更新速度也快,只更新修改的模块。 3、配置简单,开箱即用。 | 1、随着项目规模增大,启动时间和热更新速度可能变慢。 2、配置复杂,尤其是需要处理多种资源类型时。 |
依赖预构建
在首次启动 vite 时,Vite 会在本地加载站点之前预构建项目依赖。依赖预构建仅适用于开发模式。
首先 vite 会找到对应的依赖,然后调用 esbuild 将其他规范的代码转换成 ES Module 规范,然后放到当前目录下的 node_modules/.vite/deps
,同时对 ES Module 规范的各个模块进行统一集成。
- CommonJS 和 UMD 兼容性
- 解决网络多包传输的性能问题
在开发阶段中,Vite 的开发服务器将所有代码视为原生 ES 模块。在项目中使用的第三方依赖包可能是 CommonJS 或者 UMD 规范的。因此,Vite 需要先将它们转换为 ES Module。
例如在使用 lodash 时,lodash 可能也 import 了其他的第三方依赖包,浏览器会加载很多模块,造成性能问题。而 Vite 会将它们全部预构建,然后通过一个入口文件将这些模块合并成一个文件,减少网络传输。
假设项目中使用了 lodash-es
:
import lodashES from 'lodash-es';
console.log(lodashES);
查看 node_modules/lodash-es/lodash.js
会发现有一 大堆的 export
。但是查看控制台的 Network,找到 http://localhost:5173/node_modules/.vite/deps/lodash-es.js?v=1bf1d6d1
这个请求,查看发现内容被 vite 重写了。而且浏览器没有同时发出大量的 HTTP 请求,vite 将其集成到一个模块,只发出了一次 HTTP 请求。
接下来验证一下,在 vite.config.js 中配置 optimizeDeps.exclude
来排除依赖预构建。
export default defineConfig({
optimizeDeps: {
exclude: ['lodash-es']
}
});
这样 lodash-es 依赖不会被预构建,而是直接从 node_modules 中加载。查看控制台发现发出了大量的 HTTP 请求,会造成性能问题。
路径补全
vite 在处理的过程中如果遇到了不是绝对路径、也不是相对路径的引用,则会尝试开启路径补全。
// main.ts 中使用的代码
import { createApp } from 'vue';
// 打开控制台 Network,发现会自动补全路径
import { createApp } from '/node_modules/.vite/deps/vue.js?v=19b29bc2';
会自动去 node_modules 中寻找依赖。
环境变量
在项目根目录新建以下文件:
.env.development
开发环境.env.production
生产环境.env.staging
预发布环境
内容可以设置如下,配置各个环境的变量:
VITE_BASE_URL="http://baseapi.com"
VITE_FILE_URL="http://fileapi.com"
在终端执行 pnpm vite --mode staging
启动预发布环境,可以在 package.json 中配置 scripts 脚本:"staging": "vite --mode staging"
。
在项目中使用环境变量:
process.cwd()
:返回当前 node 进程的工作目录import.meta.env.VITE_BASE_URL
如果是客户端,vite 会将环境变量注入到 import.meta.env
里去
环境变量默认是以 VITE
开头的,如果想要更改这个前缀,可以使用 envPrefix
配置
配置路径别名
配置 resolve.alias
的文档,本质就是字符串替换。
import { defineConfig } from 'vite';
import path from 'path';
export default defineConfig({
resolve: {
alias: [{ find: '@', replacement: path.resolve(__dirname, 'src') }]
}
});
或者这样:
export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, 'src')
}
}
});
如 果引入path
后 TS 报错,就需要安装 @types/node
vite 是怎么让浏览器识别到 vue 文件的?
使用 vite 创建一个 vue 项目,启动开发服务器后,可以看到有一个 http://localhost:5173/src/App.vue
的请求,那么浏览器是怎么识别 vue 文件的呢?
在 Vite 创建的 Vue 项目中,在根目录有一个 index.html
,里面引入了一个 main.js
文件:
<script type="module" src="/src/main.js"></script>
我们先创建一个空项目,也创建一个 main.js
,内容先空着。然后创建一个 index.html
:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite</title>
</head>
<body>
<div id="app">Vite</div>
<script type="module" src="./main.js"></script>
</body>
</html>
接着安装 Koa,搭建开发服务器:
pnpm add koa
创建一个 index.js
文件,编写服务:
const Koa = require('koa');
const fs = require('fs');
const path = require('path');
const app = new Koa();
app.use(async ctx => {
if (ctx.request.url === '/') {
// 在服务端不会这么用,一般用中间件去处理
const indexContent = await fs.promises.readFile(path.resolve(__dirname, './index.html'));
ctx.response.body = indexContent;
ctx.response.set('Content-Type', 'text/html');
}
});
app.listen(5173, () => console.log('listen on 5173'));
执行命令:node index.js
,可以在 package.json
中添加 script 启动脚本。
然后打开 http://localhost:5173
,可以看到读取的 index.html
内容。但是这时请求 main.js 会报 404,因为还没有设置处理逻辑。
在 Vite 创建的 Vue 项目中,在浏览器查看
App.vue
这个请求结果是 js 内容。这已经是经过编译后的内容。
继续创建一个 App.vue
:
console.log('App');
修改 main.js
的内容如下:
import './App.vue';
接着处理加载 main.js
的逻辑:
if (ctx.request.url === '/main.js') {
const mainJsContent = await fs.promises.readFile(path.resolve(__dirname, './main.js'));
ctx.response.body = mainJsContent;
ctx.response.set('Content-Type', 'text/javascript');
}
重启服务,刷新页面,可以看到 main.js 已经可以加载了,但是 App.vue 依然没有加载。
继续增加处理 App.vue 的逻辑。Vite 在读取到 vue 文件时,是经过 AST 转换的,下面是简易表示:
if (ctx.request.url === '/App.vue') {
const appContent = await fs.promises.readFile(path.resolve(__dirname, './App.vue'));
ctx.response.body = appContent;
// 告诉浏览器即使遇到了 `.vue` 文件,也用 js 类型解析
ctx.response.set('Content-Type', 'text/javascript');
}
重启服务,然后就可以看到 App.vue 已经被加载了。
总结:
Vite 让浏览器识别 .vue
文件的关键是利用了浏览器原生支持 ES 模块、开发服务器的请求拦截与转码机制。
- 当在代码中写下:
import App from './App.vue
,浏览器会向开发服务器 Vite 发送对 App.vue 的请求 - Vite 内置开发服务器会拦截对 App.vue 的请求,并对其进行特殊处理。通过插件(例如 @vitejs/plugin-vue),将
.vue
文件转为 JS 模块,并将其返回给浏览器。
CSS 配置
less 配置全局变量
less 配置:less-options
可以配置 css.preprocessorOptions
来配置全局变量。例如配置 less 的全局变量:
export default defineConfig({
css: {
preprocessorOptions: {
less: {
globalVars: {
themeColor: 'red'
}
}
}
}
});
在组件中使用:
.box {
color: @themeColor;
}
sass 配置全局变量
假设有一个 variable.scss
文件用来管理全局变量:
$themecolor: red;
如果在每个 scss 文件中都引入 @import "../variable";
,会很麻烦。可以使用 additionalData
来引入,内容会在每个 scss 文件的开头自动注入。
import { normalizePath } from 'vite';
import path from 'path';
// 用 normalizePath 解决 Windows 下的路径问题
const variablePath = normalizePath(path.resolve('./src/variable.scss'));
export default defineConfig({
css: {
preprocessorOptions: {
scss: {
additionalData: `@import "${variablePath}";`
}
}
}
});
CSS Modules
vite 默认支持 CSS Modules,不需要额外配置。将文件名改为 *.module.scss
,就可以启用 CSS Modules。
localConvention
: 设置生成的类名格式:驼峰或者中划线scopeBehaviour
: 配置当前的模块化行为是模块化还是全局化,可选值:'global'
|'local'
generateScopedName
: 生成的类名规则,可以为函数或者字符串形式,参考规则: interpolatenamehashPrefix
: 在生成哈希时添加一个前缀,以确保不同项目之间的哈希值不会冲突globalModulePaths
: 指定哪些路径下的 CSS 文件不应该被模块化处理。通常用于排除第三方库中的 CSS 文件。exportGlobals
: 是否导出全局样式
export default {
css: {
modules: {
generateScopedName: '[name]__[local]___[hash:base64:5]',
localsConvention: 'camelCase',
hashPrefix: 'my-app-'
}
}
};
PostCSS 配置
PostCSS 可以在 vite.config.js 中配置:
export default defineConfig({
css: {
postcss: {}
}
});
也可以单独在项目根目录的 postcss.config.js
中配置。
常用的插件:
- postcss-preset-env:转换现代 CSS 语法,处理兼容性问题
- postcss-pxtorem:用来将 px 转换为 rem 单位,常用于移动端适配
启用 sourcemap
打开控制台,选中元素,在样式中可以看到 <style>
,但是看不到具体的源码路径。
如果想快速定位到源码,可以配置 css.devSourcemap
为 true,然后就可以看到具体的路径。
export default defineConfig({
css: {
devSourcemap: true
}
});
处理静态资源
?url
后缀可以获取资源的路径?raw
后缀可以获取资源的字符串内容
例如:在页面上展示一个 svg 图标,鼠标移上去会变红,移出时变黑
import svgIcon from './assets/svgs/fullScreen.svg?url';
import svgRaw from './assets/svgs/fullScreen.svg?raw';
console.log('svgIcon', svgIcon); // /src/assets/svgs/fullScreen.svg
console.log('svgRaw', svgRaw); // svg字符串
document.body.innerHTML = svgRaw;
const svgElement = document.getElementsByTagName('svg')[0];
svgElement.onmouseenter = function () {
this.style.fill = 'red';
};
svgElement.onmouseleave = function () {
this.style.fill = 'black';
};
vite 插件
vite 会在生命周期的不同阶段中去 调用不同的插件以达到不同的目的。
以vite-aliases为例,这个插件会自动生成路径别名
import { defineConfig } from 'vite';
import { ViteAliases } from 'vite-aliases';
export default defineConfig({
plugins: [ViteAliases()]
});
插件必须返回一个配置对象给 Vite
注意插件都是一个函数调用,函数的参数是插件的配置项,这样更灵活。例如:
module.exports = function (options) {
return {
name: options.name || 'index.js'
};
};
config 钩子
在解析 Vite 配置前调用。返回一个将被深度合并到现有配置中的部分配置对象,或直接改变配置。
例如,实现一个自定义插件:
module.exports = function (options) {
return {
config(config, env) {
console.log(config, env);
return {};
}
};
};
config 钩子的参数:
- config 参数是配置对象,即在 vite.config.js 中配置的
- env 参数是包含
command
和mode
的对象- mode 表示 production 或 development
- command 表示 serve 或 build。在执行
pnpm dev
时是 serve,执行pnpm build
时是 build
手写别名插件
在 vite 执行配置 文件之前去改写配置文件
实现一个将 src 目录下的文件夹自动生成别名的插件,例如:
{
'@': '/xxx/src'
'@assets': '/xxx/src/assets',
'@components': '/xxx/src/components'
}
步骤:
- 在 config 钩子中要返回一个 resolve 出去
- 读取 src 下的所有文件,过滤掉文件,只保留目录
- 循环文件夹, 拿到文件夹名, 拼接成别名
- 返回一个对象, 键是别名, 值是绝对路径
同步操作:
const fs = require('fs');
const path = require('path');
function filterDir(dirAndFiles = [], basePath = '') {
const dirs = [];
dirAndFiles.forEach(name => {
const currentFileStat = fs.statSync(path.resolve(__dirname, basePath + '/' + name));
const isDirectory = currentFileStat.isDirectory();
if (isDirectory) {
dirs.push(name);
}
});
return dirs;
}
async function getSrcDir(prefix) {
const resolveAliasData = {};
// 为 src 目录本身添加别名
resolveAliasData[prefix] = path.resolve(__dirname, '../src');
// 读取src目录下所有的一级文件,包括目录和文件
const result = fs.readdirSync(path.resolve(__dirname, '../src'));
// 过滤掉文件,获取目录
const dirs = filterDir(result, '../src');
dirs.forEach(dirName => {
const key = `${prefix}${dirName}`;
const absolutePath = path.resolve(__dirname, '../src' + '/' + dirName);
resolveAliasData[key] = absolutePath;
});
return resolveAliasData;
}
module.exports = ({ prefix = '@' } = {}) => {
return {
config(config, env) {
const aliasObj = getSrcDir(prefix);
return {
resolve: {
alias: aliasObj
}
};
}
};
};
异步操作:
const fs = require('node:fs/promises');
const path = require('path');
async function filterDir(dirAndFiles = [], basePath = '') {
const dirs = [];
for (let name of dirAndFiles) {
const currentFileStat = await fs.stat(path.resolve(__dirname, basePath + '/' + name));
const isDirectory = currentFileStat.isDirectory();
if (isDirectory) {
dirs.push(name);
}
}
return dirs;
}
async function getSrcDir(prefix) {
const resolveAliasData = {};
const srcPath = path.resolve(__dirname, '../src');
resolveAliasData[prefix] = srcPath;
const result = await fs.readdir(srcPath);
const dirs = await filterDir(result, '../src');
dirs.forEach(dirName => {
const key = `${prefix}${dirName}`;
const absolutePath = path.resolve(__dirname, '../src' + '/' + dirName);
resolveAliasData[key] = absolutePath;
});
return resolveAliasData;
}
module.exports = ({ prefix = '@' } = {}) => {
return {
async config(config, env) {
const aliasObj = await getSrcDir(prefix);
return {
resolve: {
alias: aliasObj
}
};
}
};
};
transformIndexHtml 钩子
转换 index.html
的专用钩子。钩子接收当前的 HTML 字符串和转换上下文。
可以使用 vite-plugin-html 实现动态的去控制 html 中的内容,使用了 ejs 模板。
import { defineConfig } from 'vite';
import { createHtmlPlugin } from 'vite-plugin-html';
export default defineConfig({
plugins: [
createHtmlPlugin({
inject: {
data: {
title: 'index'
}
}
})
]
});
在 index.html
中设置 <title><%= title %></title>
,即可获取到配置的标题。
手写转换 html 的插件
实现替换 html 标题的插件
import createHtmlPlugin from './plugins/createHtmlPlugin';
export default defineConfig({
plugins: [
createHtmlPlugin({
inject: {
data: {
title: '首页'
}
}
})
]
});
enforce: 'pre'
:将插件执行顺序提前,文档
module.exports = options => {
return {
transformIndexHtml: {
enforce: 'pre',
transform: (html, ctx) => {
return html.replace(/<%= title %>/g, options.inject.data.title || 'home');
}
}
};
};