no-bundle 的理念
通过 Vite 初始化的项目,在项目根目录中有一个 index.html 文件,这个文件十分关键。
因为 Vite 默认会把项目根目录下的 index.html 作为入口文件。也就是说,当你访问 http://localhost:3000 的时候,Vite 的 Dev Server 会自动返回这个 HTML 文件的内容。
我们来看看这个 HTML 究竟写了什么:
1 |
|
可以看到这个 HTML 文件的内容非常简洁,在 body 标签中除了 id 为 root 的根节点之外,还包含了一个声明了 type=”module” 的 script 标签
由于现代浏览器原生支持了 ES 模块规范,因此原生的 ES 语法也可以直接放到浏览器中执行,只需要在 script 标签中声明 type=”module” 即可。比如上面的 script 标签就声明了 type=”module”,同时 src 指向了 /src/main.ts 文件,此时相当于请求了 http://localhost:3000/src/main.ts 这个资源,Vite 的 Dev Server 此时会接受到这个请求,然后读取对应的文件内容,进行一定的中间处理,最后将处理的结果返回给浏览器。
这里我们需要知道的是
- Vite 会将项目的源代码编译成浏览器可以识别的代码
- 与此同时,一个 import 语句即代表了一个 HTTP 请求,Vite Dev Server 会根据这些 HTTP 请求读取本地文件,返回浏览器可以解析的代码。当浏览器解析到新的 import 语句,又会发出新的请求,以此类推,直到所有的资源都加载完成。
现在,你应该知道了 Vite 所倡导的 no-bundle 理念的真正含义:利用浏览器原生 ES 模块的支持,实现开发阶段的 Dev Server,进行模块的按需加载,而不是先整体打包再进行加载。相比 Webpack 这种必须打包再加载的传统构建模式,Vite 在开发阶段省略了繁琐且耗时的打包过程,这也是它为什么快的一个重要原因。
Vite 当中支持多种配置文件类型,包括 .js、.ts、.mjs 三种后缀的文件,实际项目中一般使用 vite.config.ts 作为配置文件。
root
如果页面的入口文件 index.html 并不在项目根目录下,而需要放到 src 目录下,那如何在访问 localhost:3000 的时候让 Vite 自动返回 src 目录下的 index.html 呢?我们可以配置 root
1 | // vite.config.ts |
当手动指定 root 参数之后,Vite 会自动从这个路径下寻找 index.html 文件
生产环境构建
有人说 Vite 因为其不打包的特性而不能上生产环境,其实这种观点是相当有误的。
在开发阶段 Vite 通过 Dev Server 实现了不打包的特性,而在生产环境中,Vite 依然会基于 Rollup 进行打包,并采取一系列的打包优化手段。
从脚手架项目的 package.json 中就可见一斑:
1 | "scripts": { |
但可能你会有点疑惑,为什么在 vite build 命令执行之前要先执行 tsc 呢?
tsc 作为 TypeScript 的官方编译命令,可以用来编译 TypeScript 代码并进行类型检查,而这里的作用主要是用来做类型检查,我们可以从项目的 tsconfig.json 中注意到这样一个配置:
1 | { |
虽然 Vite 提供了开箱即用的 TypeScript 以及 JSX 的编译能力,但实际上底层并没有实现 TypeScript 的类型校验系统,因此需要借助 tsc 来完成类型校验(在 Vue 项目中使用 vue-tsc 这个工具来完成),在打包前提早暴露出类型相关的问题,以保证代码的健壮性。
CSS 样式方案
如果我们不用任何 CSS 工程方案,会出现哪些问题?
- 开发体验欠佳。比如原生 CSS 不支持选择器的嵌套
- 样式污染问题。如果出现同样的类名,很容易造成不同的样式互相覆盖和污染
- 浏览器兼容问题。为了兼容不同的浏览器,我们需要对一些属性(如 transition)加上不同的浏览器前缀,比如 -webkit-、-moz-、-ms-、-o-,意味着开发者要针对同一个样式属性写很多的冗余代码。
- 打包后的代码体积问题。如果不用任何的 CSS 工程化方案,所有的 CSS 代码都将打包到产物中(即使有部分样式并没有在代码中使用),导致产物体积过大。
针对如上原生 CSS 的痛点,社区中诞生了不少解决方案,常见的有 5 类:
- css 预处理器 Sass/Scss、Less 和 Stylus,这些方案各自定义了一套语法,让 CSS 也能使用嵌套规则,甚至能像编程语言一样定义变量、写条件判断和循环语句,大大增强了样式语言的灵活性,解决原生 CSS 的开发体验问题。
- CSS 后处理器 PostCSS,用来解析和处理 CSS 代码,可以实现的功能非常丰富,比如将 px 转换为 rem、根据目标浏览器情况自动加上类似于–moz–、-o-的属性前缀等等。
- CSS Modules,能将 CSS 类名处理成哈希值,这样就可以避免同名的情况下样式污染的问题。
- CSS in JS 方案
- CSS 原子化框架,如 Tailwind CSS、Windi CSS,通过类名来指定样式,大大简化了样式写法,提高了样式开发的效率,主要解决了原生 CSS 开发体验的问题。
Vite 本身对 CSS 各种预处理器语言(Sass/Scss、Less 和 Stylus)做了内置支持,也就是说,即使你不经过任何的配置也可以直接使用各种 CSS 预处理器。
Vite 底层会调用 CSS 预处理器的官方库进行编译,而 Vite 为了实现按需加载,并没有内置这些工具库,而是让用户根据需要安装。所以我们依然需要安装它们。
CSS Modules 在 Vite 也是一个开箱即用的能力,Vite 会对后缀带有 .module 的样式文件自动应用 CSS Modules。接下来我们通过一个简单的例子来使用这个功能。
自动引入
每次要使用 $theme-color 属性的时候我们都需要手动引入其所在的 variable.scss 文件,那有没有自动引入的方案呢?
1 | @import "../../variable"; |
这就需要在 Vite 中进行一些自定义配置了,在配置文件中增加如下的内容:
1 | // vite.config.ts |
PostCSS
一般可以通过 postcss.config.js 来配置 postcss,不过在 Vite 配置文件中已经提供了 PostCSS 的配置入口,我们可以直接在 Vite 配置文件中进行操作。
1 | // vite.config.ts 增加如下的配置 |
CSS 原子化框架
在目前的社区当中,CSS 原子化框架主要包括 Tailwind CSS 和 Windi CSS。
Windi CSS 作为前者的替换方案,实现了按需生成 CSS 类名的功能,开发环境下的 CSS 产物体积大大减少,速度上比 Tailwind CSS v2 快 20~100 倍。
当然,Tailwind CSS 在 v3 版本也引入 JIT(即时编译) 的功能,解决了开发环境下 CSS 产物体积庞大的问题。
如果要将这两个方案接入到 Vite 中,则需要使用它们自己定义的插件和 postcss 配置。
Lint 工具链
ESLint:JS/TS 规范工具
与 Prettier 强强联合
在 Vite 中接入 ESLint
除了安装编辑器插件的方法之外,我们也可以直接通过 Vite 插件的方式在开发阶段进行 ESLint 扫描,以命令行的方式展示出代码中的规范问题,并能够直接定位到原文件。
首先我们安装 Vite 中的 ESLint 插件:
1 | pnpm i vite-plugin-eslint -D |
然后在 vite.config.ts 中接入:
1 | // vite.config.ts |
现在重新启动项目的话, ESLint 的错误就已经能够及时显示到命令行窗口中了
由于这个插件采用另一个进程来运行 ESLint 的扫描工作,因此不会影响 Vite 项目的启动速度
Stylelint:样式规范工具
Stylelint 主要专注于样式代码的规范检查,内置了 170 多个 CSS 书写规则,支持 CSS 预处理器(如 Sass、Less),提供插件化机制以供开发者扩展规则,已经被 Google、Github 等大型团队投入使用。
与 ESLint 类似,在规范检查方面,Stylelint 已经做的足够专业,而在代码格式化方面,我们仍然需要结合 Prettier 一起来使用。
首先安装 Stylelint 以及相应的工具套件:
1 | pnpm i stylelint stylelint-prettier stylelint-config-prettier stylelint-config-recess-order stylelint-config-standard stylelint-config-standard-scss -D |
然后在 Stylelint 的配置文件 .stylelintrc.js 中使用这些工具套件:
1 | // .stylelintrc.js |
可以发现 Stylelint 的配置文件和 ESLint 还是非常相似的,常用的 plugins、extends 和 rules 属性在 ESLint 同样存在,并且与 ESLint 中这三个属性的功能也基本相同。
不过需要强调的是在 Stylelint 中 rules 的配置会和 ESLint 有些区别。
接下来将 Stylelint 集成到项目中,回到 package.json 中,增加如下的 scripts 配置:
1 | { |
当然,你也可以在 VSCode 中安装 Stylelint 插件,这样能够在开发阶段即时感知到代码格式问题,提前进行修复。我们也可以直接在 Vite 中集成 Stylelint。社区中提供了 Stylelint 的 Vite 插件:vite-plugin-stylelint,实现在项目开发阶段提前暴露出样式代码的规范问题。
Husky + lint-staged 的 Git 提交工作流
安装了 ESLint、Prettier 和 Stylelint 的 VSCode 插件或者 Vite 插件后,在开发阶段提前规避掉代码格式的问题,但实际上这也只是将问题提前暴露,并不能保证规范问题能完全被解决,还是可能导致线上的代码出现不符合规范的情况。那么如何来避免这类问题呢?
我们可以在代码提交的时候进行卡点检查。使用 Husky 拦截 git commit 命令,进行代码格式检查,只有确保通过格式检查才允许正常提交代码。
如果直接在 Husky 的钩子中执行 npm run lint,这会产生一个额外的问题:Husky 中每次执行 npm run lint 都对仓库中的代码进行全量检查,也就是说,即使某些文件并没有改动,也会走一次 Lint 检查,当项目代码越来越多的时候,提交的过程会越来越慢,影响开发体验。
而 lint-staged 就是用来解决上述全量扫描问题的,可以实现只对存入暂存区的文件进行 Lint 检查,大大提高了提交代码的效率。如此一来,我们便实现了提交代码时的增量 Lint 检查。
commitlint 检查信息提交规范
静态资源处理
静态资源处理是前端工程经常遇到的问题,在真实的工程中不仅仅包含了动态执行的代码,也不可避免地要引入各种静态资源,如图片、JSON、Worker 文件、Web Assembly 文件等等。
而静态资源本身并不是标准意义上的模块,因此对它们的处理和普通的代码是需要区别对待的。
- 一方面我们需要解决资源加载的问题,对 Vite 来说就是如何将静态资源解析并加载为一个 ES 模块的问题;
- 另一方面,在生产环境下我们还需要考虑静态资源的部署问题、体积问题、网络性能问题,并采取相应的方案来进行优化。
图片加载
图片是项目中最常用的静态资源之一,本身包括了非常多的格式,诸如 png、jpeg、webp、avif、gif,当然,也包括经常用作图标的 svg 格式。这一部分我们主要讨论的是如何加载图片,也就是说怎么让图片在页面中正常显示。
三种加载图片的场景
在 HTML 或者 JSX 中,通过 img 标签来加载图片,如:
1 | <img src="../../assets/a.png"></img> |
在 CSS 中通过 background 属性加载图片,如:
1 | background: url('../../assets/b.png') norepeat; |
在 JavaScript 中,通过脚本的方式动态指定图片的 src 属性,如:
1 | document.getElementById('hero-img').src = '../../assets/c.png' |
在 Vite 中使用
配置别名:
1 | // vite.config.ts |
这样 Vite 在遇到 @assets 路径的时候,会自动帮我们定位至根目录下的 src/assets 目录。这样使用以上三种方式引入图片该别名路径都能生效
值得注意的是,alias 别名配置不仅在 JavaScript 的 import 语句中生效,在 CSS 代码的 @import 和 url 导入语句中也同样生效。
SVG 组件方式加载
我们通常希望能将 svg 当做一个组件来引入,这样我们可以很方便地修改 svg 的各种属性,而且比 img 标签的引入方式更加优雅。
SVG 组件加载在不同的前端框架中的实现不太相同,社区中也已经了有了对应的插件支持:
- Vue2 项目中可以使用 vite-plugin-vue2-svg 插件
- Vue3 项目中可以引入 vite-svg-loader
- React 项目使用 vite-plugin-svgr 插件
JSON 加载
Vite 中已经内置了对于 JSON 文件的解析,底层使用 @rollup/pluginutils 的 dataToEsm 方法将 JSON 对象转换为一个包含各种具名导出的 ES 模块
在项目中的使用方式如下:
1 | import { version } from '../../../package.json'; |
你也可以在配置文件禁用按名导入的方式:
1 | // vite.config.ts |
这样会将 JSON 的内容解析为 export default JSON.parse("xxx")
,这样会失去按名导出的能力,不过在 JSON 数据量比较大的时候,可以优化解析性能。
Web Worker 脚本
Vite 中使用 Web Worker 非常简单
我们可以新建 Header/example.js 文件:
1 | const start = () => { |
然后在 Header 组件中引入,引入的时候注意加上 ?worker 后缀,告诉 Vite 这是一个 Web Worker 脚本文件:
1 | import Worker from './example.js?worker'; |
打开浏览器的控制面板,你可以看到 Worker 传给主线程的信息已经成功打印:
Web Assembly 文件
Vite 对于 .wasm 文件也提供了开箱即用的支持
其它静态资源
除了上述的一些资源格式,Vite 也对下面几类格式提供了内置的支持:
- 媒体类文件,包括 mp4、webm、ogg、mp3、wav、flac 和 aac
- 字体类文件。包括 woff、woff2、eot、ttf 和 otf
- 文本类。包括 webmanifest、pdf 和 txt
也就是说,你可以在 Vite 将这些类型的文件当做一个 ES 模块来导入使用。
如果你的项目中还存在其它格式的静态资源,你可以通过 assetsInclude 配置让 Vite 来支持加载:
1 | // vite.config.ts |
特殊资源后缀
Vite 中引入静态资源时,也支持在路径最后加上一些特殊的 query 后缀,例如:
- ?url:表示获取资源的路径,这在只想获取文件路径而不是内容的场景将会很有用。
- ?raw:表示获取资源的字符串内容,如果你只想拿到资源的原始内容,可以使用这个后缀。
- ?inline::表示资源强制内联,而不是打包成单独的文件。
静态资源的生产环境处理
在前面的内容中,我们围绕着如何加载静态资源这个问题,在 Vite 中进行具体的编码及配置。
但另一方面,在生产环境下,我们又面临着一些新的问题:
自定义部署域名
一般在我们访问线上的站点时,站点里面一些静态资源的地址都包含了相应域名的前缀,如:
1 | <img src="https://sanyuan.cos.ap-beijing.myqcloud.com/logo.png" /> |
以上面这个地址例子,https://sanyuan.cos.ap-beijing.myqcloud.com 是 CDN 地址前缀,/logo.png 则是我们开发阶段使用的路径。
那么,我们是不是需要在上线前把图片先上传到 CDN,然后将代码中的地址手动替换成线上地址呢?那这样就太麻烦了。
在 Vite 中我们可以有更加自动化的方式来实现地址的替换,只需要在配置文件中指定 base 参数即可:
1 | // vite.config.ts |
注意在项目根目录新增的两个环境变量文件 .env.development
和 .env.production
,顾名思义,即分别在开发环境和生产环境注入一些环境变量,这里为了区分不同环境我们加上了 NODE_ENV,你也可以根据需要添加别的环境变量。
打包的时候 Vite 会自动将这些环境变量替换为相应的字符串
接着执行 pnpm run build,可以发现产物中的静态资源地址已经自动加上了 CDN 地址前缀了
有时候可能项目中的某些图片需要存放到另外的存储服务,我们同样可以通过定义环境变量的方式来解决这个问题,在项目根目录新增 .env 文件:
1 | // 开发环境优先级: .env.development > .env |
然后进入 src/vite-env.d.ts 增加类型声明:
1 | /// <reference types="vite/client" /> |
值得注意的是,如果某个环境变量要在 Vite 中通过 import.meta.env 访问,那么它必须以 VITE_ 开头,如 VITE_IMG_BASE_URL
接下来我们在组件中来使用这个环境变量:
1 | <img :src="new URL('./logo.png', import.meta.env.VITE_IMG_BASE_URL).href" /> |
接下来在开发环境启动项目或者生产环境打包后可以看到环境变量已经被替换,地址能够正常显示:
至此,我们就彻底解决了图片资源生产环境域名替换的问题。
单文件 or 内联
在 Vite 中,所有的静态资源都有两种构建方式:
- 一种是打包成一个单文件
- 另一种是通过 base64 编码的格式内嵌到代码中
这两种方案到底应该如何来选择呢?
对于比较小的资源,适合内联到代码中,一方面对代码体积的影响很小,另一方面可以减少不必要的网络请求,优化网络性能。
而对于比较大的资源,就推荐单独打包成一个文件,而不是内联了,否则可能导致上 MB 的 base64 字符串内嵌到代码中,导致代码体积瞬间庞大,页面加载性能直线下降。
Vite 中内置的优化方案是这样的:
- 如果静态资源体积 >= 4KB,则提取成单独的文件
- 如果静态资源体积 < 4KB,则作为 base64 格式的字符串内联
当然,这个临界值你可以通过 build.assetsInlineLimit
自行配置,如下代码所示:
1 | // vite.config.ts |
svg 格式的文件不受这个临时值的影响,始终会打包成单独的文件,因为它和普通格式的图片不一样,需要动态设置一些属性
图片压缩
图片资源的体积往往是项目产物体积的大头,如果能尽可能精简图片的体积,那么对项目整体打包产物体积的优化将会是非常明显的。
在 JavaScript 领域有一个非常知名的图片压缩库 imagemin,作为一个底层的压缩工具,前端的项目中经常基于它来进行图片压缩,比如 Webpack 中大名鼎鼎的 image-webpack-loader。
在 Vite 社区当中也已经有了开箱即用的 Vite 插件:vite-plugin-imagemin
可以在 Vite 配置文件中引入:
1 | //vite.config.ts |
接下来我们可以尝试执行 pnpm run build 进行打包:
Vite 插件已经自动帮助我们调用 imagemin 进行项目图片的压缩,可以看到压缩的效果非常明显,强烈推荐大家在项目中使用。
雪碧图优化
将所有的 svg 内容都内联到 HTML 中,省去了大量 svg 的网络请求。
依赖预构建
Vite 是一个提倡 no-bundle 的构建工具,相比于传统的 Webpack,能做到开发时的模块按需编译,而不用先打包完再加载。但模块代码其实分为两部分:
- 一部分是源代码,也就是业务代码
- 另一部分是第三方依赖的代码,即 node_modules 中的代码
所谓的 no-bundle 只是对于源代码而言,对于第三方依赖而言,Vite 还是选择 bundle(打包),并且使用速度极快的打包器 Esbuild 来完成这一过程,达到秒级的依赖编译速度。
接下来我们将要熟悉 Vite 的预构建功能,深入体会各个配置的应用场景和使用姿势,学会在实战中驾驭预构建的能力。
为什么需要预构建?
为什么在开发阶段我们要对第三方依赖进行预构建?如果不进行预构建会怎么样?
首先 Vite 是基于浏览器原生 ES 模块规范实现的 Dev Server,不论是应用代码,还是第三方依赖的代码,理应符合 ESM 规范才能够正常运行。
但可惜,我们没有办法控制第三方的打包规范。
就目前来看,还有相当多的第三方库仍然没有 ES 版本的产物,比如大名鼎鼎的 react:
1 | // react 入口文件 |
这种 CommonJS 格式的代码在 Vite 当中无法直接运行,我们需要将它转换成 ESM 格式的产物。
此外,还有一个比较重要的问题——请求瀑布流问题。
比如说,知名的 loadsh-es 库本身是有 ES 版本产物的,可以在 Vite 中直接运行。但实际上,它在加载时会发出特别多的请求,导致页面加载的前几秒几都乎处于卡顿状态,拿一个简单的 demo 项目举例,请求情况如下图所示:
每个 import 都会触发一次新的文件请求,因此在这种依赖层级深、涉及模块数量多的情况下,会触发成百上千个网络请求。
巨大的请求量加上 Chrome 对同一个域名下只能同时支持 6 个 HTTP 并发请求的限制,导致页面加载十分缓慢,与 Vite 主导性能优势的初衷背道而驰。
解决方案就是依赖预构建,在进行依赖的预构建之后,lodash-es 这个库的代码被打包成了一个文件,这样请求的数量会骤然减少,页面加载也快了许多。
下图是进行预构建之后的请求情况,你可以对照看看:
总之,依赖预构建主要做了两件事情:
- 将其他格式(如 UMD 和 CommonJS)的产物转换为 ESM 格式,使其在浏览器通过