-
打个分吧:

Vite笔记

Vite组成、原理、配置、插件、性能优化

14分钟阅读
-
-

Vite 读 /vit/

资源

Vite发展&现状

Vite基本概念

Vite和webpack一样都是构建工具。

使用webpack打包大型的前端应用,启动开发服务器时webpack必须优先抓取并分析 整个应用的依赖 ,由于大型项目包含大量模块,可能需要很长时间(甚至是几分钟)才能启动dev server,文件修改后的HMR也可能长达几时秒到一分钟,极大影响开发者的开发效率。

Vite就是为了解决这个问题而诞生。

目前Vite主要由两部分组成:

  • 一个开发服务器,服务于开发环境,它基于 原生 ES 模块 和ESBuild,提供了 丰富的内建功能:如速度很快的 模块热更新(HMR)。其原理是利用浏览器去解析 imports,在服务器端按需编译返回,完全跳过了打包这个概念,服务器随起随用。
  • 一套构建指令,用于生产环境打包,使用 Rollup 打包代码,并且它是预配置的,可输出用于生产环境的高度优化过的静态资源。

上手

使用npm create vite@latest命令初始化一个Vite项目

有六种框架可选:

选择vue框架,初始化后的目录结构是这样:

相较于过去基于webpack的vue项目,有2个不同之处。

1是index.html从/public移出到了根目录,这是因为对于Vite项目而言,index.html就是一切依赖的入口。

2是Vite.config.ts 替代了 vue.config.js。

Vite其他命令如下:

  • Vite启动开发服务器
  • Vite build 打包
  • Vite optimize 预构建依赖
  • Vite preview 本地预览打包产物

本地启动项目后,可以看到index.html中以原生ES模块的方式,引入了main.js和其他依赖:

对于浏览器对.vue文件的HTTP请求,通过设置Content-Type: application/javascript,被处理成了浏览器可识别的js逻辑,并且js中用的也是原生ES模块的导入导出语法:

本地打包后,启动preview服务器查看效果:

学Vite的几个前置条件

1 了解ES Module

Vite基于原生ESM实现。

现在js程序越来越复杂,因此近年来Nodejs都支持将 JavaScript 程序拆分为可按需导入的单独模块的机制,例如,CommonJS 和基于 AMD 的其他模块系统 如 RequireJSWebpackBabel

但是这类模块系统服务器启动缓慢,经历一条很长的编译打包链条,从入口开始需要逐步经历语法解析、依赖收集、代码转译、打包合并、代码优化,最终将高版本的、离散的源码编译打包成低版本、高兼容性的产物代码,流程如下图:

最新的浏览器开始原生支持模块功能、浏览器支持es语法中的import和export语法

可以通过 <script type="module" src="xx"> 引入模块

浏览器通过HTTP请求的方式,按需加载每一个被引入的模块,在启动开发服务器时,可以达到免打包、随开随用的效果,流程如下:

2 了解ESBuild

那么Vite为什么不直接使用浏览器原生esm,还要用ESBuild呢?

因为浏览器原生ESM功能有限,例如:只支持用完整的相对路径/绝对路径引入模块,不支持引入裸模块(以模块名的方式从node_modules下引入模块),会报错,举个例子引入lodash的报错:

为什么浏览器不支持自动从node_modules下寻找并引入模块?如果某个模块依赖项很多,而每个模块又会产生一次HTTP请求,会导致严重的HTTP请求并发问题。

ESBuild 会对依赖进行 预构建,对原生的ESM功能进行了扩展。

另外,ESBuild 使用 Go 编写,比以 JavaScript 编写的打包器预构建依赖快 10-100 倍。(文章 - ESBuild 为什么那么快

3 了解Rollup

对于生产环境,Vite会使用rollup进行生产环境的构建和打包。

ESBuild 也支持打包,为何不用 ESBuild 打包?

目前Vite团队认为 Rollup 提供了更好的性能与灵活性方面的权衡:Vite 目前的插件 API 与使用 ESBuild 作为打包器并不兼容。尽管 ESBuild 速度更快,但 Vite 采用了 Rollup 灵活的插件 API 和基础建设,这对 Vite 在生态中的成功起到了重要作用。

(即便如此,ESBuild 在过去几年有了很大进展,Vite不排除在未来使用 ESBuild 进行生产构建的可能性。)

近年来对于构建工具的选择,主流的经验是:

关于Webpack和Rollup的区别,推荐阅读一篇2017年的Medium文章:Webpack and Rollup: the same but different

相较于Rollup,Webpack有着丰富庞大的生态系统、大量的contributors和sponsors,rollup的生态并没有webpack工具强大。

但Rollup是React, Vue, Ember, Preact, D3, Three.js, Moment等众多知名开源库选择的构建工具,rollup相对webpack更轻量,其构建的代码并不会像webpack一样被注入大量的webpack内部结构,而是尽量的精简保持代码原有的状态。它同样支持tree-shaking、依赖解析等能力。

Dev Server 功能 & 原理

我在本地进行了实际对比:

使用vue cli创建一个webpack+vue的空项目,启动devserver时间在10s左右;

使用Vite创建一个Vite+vue的空项目,启动dev server的时间在1s-2s左右。

Vite本地启动比webpack快很多,那么Vite启动本地服务器的原理是什么?

Vite 会将应用代码分为 依赖源码 两类,对不同的模块进行按需编译。

1 依赖预构建

  • 对于依赖:当首次启动 dev server 时,Vite 会在本地加载站点之前预构建依赖。
  • 默认情况下,预构建是自动且透明地完成的,可以在Vite.config.js配置中禁用。
  • Vite 底层使用 ESBuild 预构建依赖( Esbuild 使用 Go 编写,比以 JavaScript 编写的打包器预构建依赖快 10-100 倍)。

兼容导出格式

为了兼容不同的第三方依赖包的不同的导出格式(例如浏览器不支持的commonjs),ESBuild会将所有依赖包转换为浏览器支持的esm格式,然后放到当前目录 node_modules/.Vite/deps下。

多包传输优化

为了解决浏览器网络多包传输的性能问题,ESBuild会分析依赖链,将多模块的import集成到一个模块中去,减少 http 请求数。例如引入lodash-es包的合并前后对比:

补齐import路径

浏览器只支持完整相对路径/绝对路径形式引入模块,对于裸模块、省略了文件后缀名的模块,预构建阶段会对路径进行补齐和重写。例如引入lodash包:

import _ from "lodash";

强缓存

依赖包的HTTP请求会被Vite设置为 Cache-Control: max-age=31536000,immutable 进行强缓存。

2 源码转换

对于源码:JSX、CSS 或者 Vue SFC等,Vite会对其进行转换,以 原生 ESM 方式提供源码。

Vite对vue文件的处理

对于.vue文件:Vite会使用vue-compiler将其转为js,通过设置Content-Type:text/javascript,让浏览器将.vue文件识别为js文件并解析。

Vite对css的处理

对于css文件:Vite会将浏览器.css文件的HTTP请求设置Content-Type:text/javascript,然后把css文件的内容替换为一段js代码,这段js代码的逻辑就是将对应的css代码作为 <style>标签插入到html文件中,并且支持css的热更新功能。

CSS代码分割:Vite 会自动地将一个异步 chunk 模块中使用到的 CSS 代码抽取出来并为其生成一个单独的文件(而不是将所有的 CSS 抽取到一个文件中),这个 CSS 文件将在该异步 chunk 加载完成时自动通过一个 <link> 标签载入,该异步 chunk 会保证只在 CSS 加载完毕后再执行,避免发生 FOUC

Vite对css模块化的处理

将所有.module.css后缀的文件视为模块化css,例子:

Foo.module.css文件:
.footer{
  width: 100px;
  height: 100px;
  background-color: rosybrown;
}

Bar.module.css文件:
.footer{
  width: 100px;
  height: 100px;
  background-color: rosybrown;
}

对其css代码中的所有类名进行 哈希转换,创建映射对象

<script setup>
import css from "@/styles/modules/Bar.module.css";
console.log('Bar.module.css映射对象', css);
</script>

然后将原始css文件中的代码抹除,替换成一段JS脚本,这段脚本会将上述的映射对象 默认导出:

这段脚本的逻辑是将哈希转换后的css代码,作为 <style>标签插入html中:

Vite对less和scss的处理

Vite 也同时提供了对 .scss, .sass, .less, .styl.stylus 文件的内置支持。没有必要为它们安装特定的 Vite 插件,但必须安装相应的预处理器依赖:

# .scss and .sass
npm add -D sass

# .less
npm add -D less

# .styl and .stylus
npm add -D stylus

Vite调用各个css预处理器将.scss .less转换为css代码后,对css代码的处理同上述【Vite对css的处理】

协商缓存

  • 源码的HTTP请求会被Vite设置为 304 Not Modified 进行协商缓存

3 静态资源处理

尽量遵循 ESM 方式提供静态资源

Vite 尽量避免直接处理静态资源,而是选择遵循 ESM 方式提供服务,例如引入静态资源图片 import img from 'xxx.png' ,执行后会返回解析后的 URL,供浏览器直接请求:

  • 常见的图像、媒体和字体文件类型会被自动检测为资源,可以通过assetsInclude 选项配置文件后缀名。
  • 代码中引入的静态资源,会返回解析后的公共路径:
import imgUrl from './img.png'
document.getElementById('hero-img').src = imgUrl
  • Vue SFC <template>中的资源引用,会被自动转换为import语句。

  • 资源体积小于 assetsInlineLimit 选项值 (默认4KB)会被转换为内联的 base64 data URL。

  • 在 CSS 中引用的 url(),也是同样的处理方式。

  • 如果需要显式引入一个资源的url,可以加 ?url后缀:

    import workletURL from 'extra-scalloped-border/worklet.js?url'
    CSS.paintWorklet.addModule(workletURL)
  • 如果需要将资源引入为字符串,可以使用 ?raw 后缀:

    import shaderString from './shader.glsl?raw'
  • public目录:对于不会被源码引入、保持原文件名不加hash的文件,可以放在项目根目录下public目录中,开发时必须直接通过“/”根路径访问,打包时也会被完整复制到根目录下。

  • 支持JSON的具名导入(解构导入)

使用 new URL(url, import.meta.url)

import.meta.url 是一个 现代浏览器支持的 ESM 的原生功能,会暴露当前模块的 URL。将它与原生的 URL 构造器 组合使用,在一个 JavaScript 模块中,通过相对路径我们就能得到一个被完整解析的静态资源 URL:

const imgUrl = new URL('./img.png', import.meta.url).href

document.getElementById('hero-img').src = imgUrl

在dev server使用上述方法,可以借助浏览器的原生能力,而不需要Vite做处理。

对于生产构建时,Vite 会进行必要的转换保证 URL 在打包和资源哈希后仍指向正确的地址。

Vite打包功能

打包入口

默认情况下,以 <root>/index.html 作为打包入口,并生成能够静态部署的应用程序包。

MPA多页应用:在 build.rollupOptions.input数组中,定义多个html入口即可。

hash

打包后的资源文件名有hash,与文件内容一一对应(文件内容更新才会更新hash值,便于控制浏览器缓存)。

浏览器兼容性

Vite不支持低版本浏览器:

  • 默认情况下 Vite 只处理语法转译,且 默认不包含任何 polyfill
  • 默认情况下,Vite 的目标是 支持原生 ESM支持原生 ESM 动态导入import.meta 的浏览器:Chrome >=87,Firefox >=78,Safari >=14,Edge >=88
  • 可以通过 build.target 配置项指定构建目标,最低支持 es2015。
  • 可以通过插件 @Vitejs/plugin-legacy 支持传统浏览器,最低支持到IE 11

rollup配置

Vite基于Rollup工具打包,可以通过 build.rollupOptions 直接调整底层的 Rollup 选项

base路径

如果需要 在嵌套的服务器路径下部署项目,可以指定 base 配置项

build watch

可以使用 Vite build --watch 来启用 rollup 的监听器,文件变化时重新构建。

预加载优化

Vite 会为入口 chunk 和它们在打包出的 HTML 中的直接引入自动生成 <link rel="modulepreload"> 指令,实现预加载优化。

Vite配置文件

配置文件的语法提示

Vite的配置文件是 Vite.config.js,可以直接写成:

export default {}

但是,可以利用 defineConfig获得语法提示和类型约束:

import { defineConfig } from "Vite";

export default defineConfig({})

也可以利用IDE和jsdoc支持,来获得语法提示

/** @type {import('vite').UserConfig} */
export default {
  // ...
}

不同环境配置

类似于webpack.dev.config、webpack.prod.config

Vite在配置文件提供环境区分:

export default defineConfig(({ command, mode, ssrBuild }) => {
  if (command === 'serve') {
    return {
      // dev 独有配置
    }
  } else {
    // command === 'build'
    return {
      // build 独有配置
    }
  }
})

可以采用更具有扩展性的方式:

import { defineConfig } from "Vite";
import ViteBaseConfig from "./Vite.base.config";
import ViteDevConfig from "./Vite.dev.config";
import ViteProdConfig from "./Vite.prod.config";

// 策略模式
const envResolver = {
  serve: ()=>Object.assign({}, ViteBaseConfig, ViteDevConfig),
  build: ()=>Object.assign({}, ViteBaseConfig, ViteProdConfig)
}
export default defineConfig(({ command, mode, ssrBuild }) => {
  return envResolver[command]();
});

环境变量

Vite内置了dotenv库:支持读取 .env文件。

.env                # 所有情况下都会加载
.env.local          # 所有情况下都会加载,但会被 git 忽略
.env.[mode]         # 只在指定模式下加载
.env.[mode].local   # 只在指定模式下加载,但会被 git 忽略

Vite会解析.env文件中的所有环境变量注入到node端 process.env对象中; 解析其中Vite_ 前缀的环境变量,注入到客户端的 import.meta.env对象中。

但是 Vite.config.js执行时,默认是不加载 .env 文件的,因为这些文件需要在执行完 Vite 配置后才能确定加载哪一个,举个例子:Vite.config.js中的 envDir 选项会影响到.env的加载。

如果需要提前在Vite.config.js中获取环境变量,可以使用 Vite 的 loadEnv 函数来加载指定的 .env 文件:

export default defineConfig(({ command, mode, ssrBuild }) => {
  // 根据mode加载.env文件,默认是.env.development和.env.production,可以在Vite命令后通过--mode配置
  // process.cwd()指定env文件目录是当前执行node脚本所在的目录
  // 第三个参数是变量前缀,设置为''会加载所有环境变量,而不管是否有 `Vite_` 前缀。
  const env = loadEnv(mode, process.cwd(), '')

  console.log(env);
});

路径别名

resolve:{
    alias: {
      '@': path.resolve(__dirname,'../', 'src')
    }
  }

当使用文件系统路径的别名时,要始终使用绝对路径。相对路径的别名值会原封不动地被使用,因此无法被正常解析。

css配置

配置项 css.modules 定义了CSS modules 的行为,会被传递给 postcss-modules

  • css.modules.localsConvention:配置了对css模块类名转换后,映射对象中key的写法,camelCase驼峰,dashes中划线

配置项目 css.preprocessorOptions 指定了传递给 CSS 预处理器的选项。文件扩展名用作选项的键。每个预处理器支持的选项可以在它们各自的文档中找到:

  • sass/scss - 选项
  • less - 选项
  • styl/stylus - 仅支持 define,可以作为对象传递。

配置项 css.postcss 与postcss.config.js文件的起同等作用。

proxy代理配置

通过server.proxy为开发服务器配置自定义代理规则。

相关配置内容继承自 http-proxy

Vite插件

Vite插件作用是什么

Vite在不同的生命周期中取调用不同的插件,以达到不同的目的。

使用插件

可选的插件:官方插件社区插件部分兼容Vite的rollup插件

例子:引入并使用插件 Vite-aliases (自动根据项目目录生成 路径别名配置)

npm i Vite-aliases -D

// Vite.config.js
import { ViteAliases } from 'Vite-aliases'

export default {
  plugins: [
    ViteAliases()
  ]
};

假如项目目录是:

src
  assets
  components
  pages
  store
  utils

会自动生成如下配置:

[
  {
    find: '@',
    replacement: '${your_project_path}/src'
  },
  {
    find: '@assets',
    replacement: '${your_project_path}/src/assets'
  },
  {
    find: '@components',
    replacement: '${your_project_path}/src/components'
  },
  {
    find: '@pages',
    replacement: '${your_project_path}/src/pages'
  },
  {
    find: '@store',
    replacement: '${your_project_path}/src/store'
  },
  {
    find: '@utils',
    replacement: '${your_project_path}/src/utils'
  },
]

插件原理 & 插件开发

插件开发文档: https://cn.Vitejs.dev/guide/api-plugin.html

1 插件代码结构

Vite插件应该是一个“返回实际插件对象的工厂函数”:

export default function myPlugin(config) {
  return {
    name: 'xx',
    transform(src, id) {
      // 此处transform是一个rollup和Vite通用的钩子
    },
  }
}

2 插件钩子

开发插件最核心的逻辑就是搞清楚:我的插件应该在哪些Vite生命周期做哪些事情。

因此要先了解Vite提供了哪些生命周期钩子:

  • 通用钩子:在开发中,Vite 开发服务器会创建一个插件容器来调用 Rollup 构建钩子,与 Rollup 如出一辙:

    • 在dev server启动时被调用的钩子:
      • options(options: InputOptions) =>InputOptions | null。构建阶段第一个钩子,替换或操作传递给 rollup 的选项对象,在 Rollup 配置完成之前运行。
      • buildStart(options: InputOptions) =>void。在options后被调用,可以读取到最终options值,被 rollup.rollup 方法调用,如果仅需要读取options推荐使用这个钩子。
    • 在每个模块请求时被调用的钩子:
      • resolveId :定义自定义解析器,可以用于定位第三方依赖项等。
      • load:定义自定义加载器。
      • transform:用于转换单个模块。类似于webpack中的loader,在Vite源码有使用这个钩子转译vue文件以及template模板。
  • Vite 独有钩子:服务于特定的 Vite 目标。这些钩子会被 Rollup 忽略。

    • config:在解析 Vite 配置前调用。接收原始用户配置,可以返回一个将被deepMerge到现有配置中的部分配置对象。
    • configResolved:在解析 Vite 配置后调用。使用这个钩子读取和存储最终解析的配置。
    • configureServer:用于配置dev server的钩子,最常见的用例是给dev server添加自定义中间件,会在内部中间件被安装前调用。中间件API server.middleware.use((req,res,next)=>{})
    • configurePreviewServer:用于预览服务器。用上一个用法类似。
    • transformIndexHtml:转换 index.html 的专用钩子。钩子接收当前的 HTML 字符串和转换上下文 ctxctx在开发期间暴露ViteDevServer实例,在构建期间暴露 Rollup 输出的包。
    • handleHotUpdate:自定义 HMR 行为。

3 插件执行顺序

可以指定插件的enforce属性,来调整插件的执行顺序,enforce=pre/post。

所有插件的执行顺序如下:

  • Alias
  • 带有 enforce: 'pre' 的用户插件
  • Vite 核心插件
  • 没有 enforce 值的用户插件
  • Vite 构建用的插件
  • 带有 enforce: 'post' 的用户插件
  • Vite 后置构建插件(最小化,manifest,报告)

4 区分mode执行插件

默认情况下插件在开发(serve)和构建(build)模式中都会调用。

如果插件只需要在预览或构建期间有条件地应用,使用 apply 属性指明它们仅在 'build''serve' 模式时调用

function myPlugin() {
  return {
    name: 'build-only',
    apply: 'build' // 或 'serve'
  }
}

Vite 对 TS的支持

  • Vite 天然支持引入 .ts 文件。
  • Vite 使用 ESBuild 将 TypeScript 转译到 JavaScript,约是 tsc 速度的 20~30 倍。
  • Vite只负责转译ts代码,不负责类型检查。(Vite默认类型检查已经被你的 IDE 或构建过程处理了。)(原因是类型检查会影响Vite的速度:转译在每个文件的基础上进行,与 Vite 的按需编译模式完全吻合。相比之下,类型检查需要了解整个模块图。把类型检查塞进 Vite 的转换管道,将不可避免地损害 Vite 的速度优势。)

Vite不负责类型检查,怎么获得更多类型检查提示?

  • 开发时,在项目中使用ts,并添加tsconfig.json配置,就可以依赖IDE自带的ts检查。
  • 开发时,可以在一个单独的进程中运行 tsc --noEmit --watch,可以获得更多IDE提示。
  • 打包时,可以这样运行 Vite 构建命令: tsc --noEmit && Vite build,如果检查到类型错误,会报错并停止打包。
  • 也可以使用Vite-plugin-checker插件,会将ts类型错误抛到浏览器中。

Vite性能优化

分包策略

把一些不会经常更新的依赖,单独打包:通过配置 build.rollupOptions.output.manualChunks 来自定义 chunk 分割策略:

({
  manualChunks: (id) => {
    if (id.includes("node_modules")) {
      return "vendor";
    }
  },
});

或者直接使用Vite4中内置的 splitVendorChunkPlugin 插件,会自动拆分vender chunk:

// Vite.config.js
import { splitVendorChunkPlugin } from "vite";
export default defineConfig({
  plugins: [splitVendorChunkPlugin()],
});

gzip压缩

使用 Vite-plugin-compression 插件,打包后会生成静态资源的压缩文件,后缀是.gz,直接提供给浏览器去解压缩。由于消耗浏览器资源,只针对大的文件进行压缩;

这种方式与nginx的gzip压缩区别在哪?nginx上的资源是以原始文件形式存在的,当返回给浏览器的时候再进行压缩,浏览器收到压缩文件再解压缩。是动态压缩,会消耗服务器资源。如果提前压缩好.gz文件,可以节省服务器压力。

动态导入

动态导入也是es原生支持的特性。

例如使用动态导入引入一个异步组件4:

import { defineAsyncComponent } from "vue";

const Bar = defineAsyncComponent(() => import("@cmp/Bar/index.vue"));

打包后,Bar组件的代码会被单独打包为一个chunk,在使用到Bar组件时浏览器会按需加载这个chunk。

Glob导入

Vite 支持使用特殊的 import.meta.glob 函数从文件系统导入多个模块:const modules =import.meta.glob('./dir/*.js')

匹配到的文件默认是懒加载的,通过动态导入实现,并会在构建时分离为独立的 chunk。

避免单屏拥有过度数目的依赖

举个例子:lodash-es是lodash为了支持构建工具tree-shaking而诞生的包,包中导出了600多个模块,如果我们在vite项目中不小心写了以下代码:

import * as lodash from 'lodash-es';

这将会导致dev server模式下,页面出现严重卡顿,因为浏览器会将这600多个子模块全部作为HTTP请求,进而导致了严重的HTTP请求并发问题。

如上动态演示,这些请求都会阻塞DOM的渲染,降低开发效率。

上次更新:

评论区