跳到主要内容

Vite

概览

基于 ES Module,因为浏览器原生支持 ES Module

生产环境使用 Rollup 打包,开发环境使用 ESBuild 打包。

还有一个正在进行中的工作,即构建一个名为 Rolldown 的 Rust 版本的 Rollup。一旦 Rolldown 准备就绪,它就可以在 Vite 中取代 Rollup 和 ESBuild,显著提高构建性能,并消除开发和构建之间的不一致性。

创建项目:

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 利用了浏览器原生支持的 ES 模块。只要在 script 标签上添加 type="module" 标记, main.js 就可以直接使用 import 语法去动态导入 js 文件。

路径补全

vite 在处理的过程中如果遇到了不是绝对路径、也不是相对路径的引用,则会尝试开启路径补全。

// main.ts 中使用的代码
import { createApp } from 'vue';

// 打开控制台 Network,发现会自动补全路径
import { createApp } from '/node_modules/.vite/deps/vue.js?v=19b29bc2';

会自动去 node_modules 中寻找依赖

依赖预构建

在首次启动 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 请求,会造成性能问题。

环境变量

在项目根目录新建以下文件:

  • .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文档,本质就是字符串替换。

vite.config.ts
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

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 文件,编写服务:

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的逻辑:

index.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 模块、开发服务器的请求拦截与转码机制

  1. 当在代码中写下:import App from './App.vue,浏览器会向开发服务器 Vite 发送对 App.vue 的请求
  2. 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 文件用来管理全局变量:

src/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: 生成的类名规则,可以为函数或者字符串形式,参考规则: interpolatename
  • hashPrefix: 在生成哈希时添加一个前缀,以确保不同项目之间的哈希值不会冲突
  • 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 中配置。

常用的插件:

启用 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 参数是包含 commandmode 的对象
    • mode 表示 production 或 development
    • command 表示 serve 或 build。在执行pnpm dev时是 serve,执行pnpm build时是 build

手写别名插件

在 vite 执行配置文件之前去改写配置文件

实现一个将 src 目录下的文件夹自动生成别名的插件,例如:

{
'@': '/xxx/src'
'@assets': '/xxx/src/assets',
'@components': '/xxx/src/components'
}

步骤:

  1. 在 config 钩子中要返回一个 resolve 出去
  2. 读取 src 下的所有文件,过滤掉文件,只保留目录
  3. 循环文件夹, 拿到文件夹名, 拼接成别名
  4. 返回一个对象, 键是别名, 值是绝对路径

同步操作:

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 模板。

vite.config.js
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 标题的插件

vite.config.js
import createHtmlPlugin from './plugins/createHtmlPlugin';

export default defineConfig({
plugins: [
createHtmlPlugin({
inject: {
data: {
title: '首页'
}
}
})
]
});

enforce: 'pre':将插件执行顺序提前,文档

plugins/createHtmlPlugin.js
module.exports = options => {
return {
transformIndexHtml: {
enforce: 'pre',
transform: (html, ctx) => {
return html.replace(/<%= title %>/g, options.inject.data.title || 'home');
}
}
};
};