目录
原生 ES 导入不支持下面这样的裸模块导入:
import { someMethod } from 'my-dep'
上面的代码会在浏览器中抛出一个错误。Vite 将会检测到所有被加载的源文件中的此类裸模块导入,并执行以下操作:
- 预构建它们可以提高页面加载速度,并将 CommonJS / UMD 转换为 ESM 格式。预构建这一步由 esbuild 执行,这使得 Vite 的冷启动时间比任何基于 JavaScript 的打包器都要快得多。
- 重写导入为合法的 URL,例如
/node_modules/.vite/my-dep.js?v=f3sf2ebd
以便浏览器能够正确导入它们。
依赖预构建
当你首次启动 vite
时,你可能会注意到打印出了以下信息:
Pre-bundling dependencies:(预构建依赖)
vue
(this will be run only when your dependencies or config have changed)(这将只会在你的依赖或配置文件发生变化时执行)
原因
这就是 Vite 执行的所谓的“依赖预构建”。这个过程有两个目的:
- CommonJS 和 UMD 兼容性: 开发阶段中,Vite 的开发服务器将所有代码视为原生 ES 模块。因此,Vite 必须先将作为 CommonJS 或 UMD 发布的依赖项转换为 ESM。
当转换 CommonJS 依赖时,Vite 会执行智能导入分析,这样即使导出是动态分配的(如 vue),按名导入也会符合预期效果:
// 符合预期
import { createApp } from 'vue'
性能: Vite 将有许多内部模块的 ESM 依赖关系转换为单个模块,以提高后续页面加载性能。
一些包将它们的 ES 模块构建作为许多单独的文件相互导入。例如,lodash-es 有超过 600 个内置模块!当我们执行 import { debounce } from 'lodash-es'
时,浏览器同时发出 600 多个 HTTP 请求!尽管服务器在处理这些请求时没有问题,但大量的请求会在浏览器端造成网络拥塞,导致页面的加载速度相当慢。
通过预构建 lodash-es
成为一个模块,我们就只需要一个 HTTP 请求了!
自动依赖搜索
如果没有找到相应的缓存,Vite 将抓取你的源码,并自动寻找引入的依赖项(即 "bare import",表示期望从 node_modules
解析),并将这些依赖项作为预构建包的入口点。预构建通过 esbuild
执行,所以它通常非常快。
在服务器已经启动之后,如果遇到一个新的依赖关系导入,而这个依赖关系还没有在缓存中,Vite 将重新运行依赖构建进程并重新加载页面。
自定义行为
默认的依赖项发现为启发式可能并不总是可取的。在你想要显式地从列表中包含/排除依赖项的情况下, 请使用 optimizeDeps 配置项。
当你遇到不能直接在源码中发现的 import 时,optimizeDeps.include
或 optimizeDeps.exclude
就是典型的用例。例如,import 可能是插件转换的结果。这意味着 Vite 无法在初始扫描时发现 import —— 它只能在浏览器请求文件时转换后才能发现。这将导致服务器在启动后立即重新打包。
- optimizeDeps.exclude#
-
类型:
string[]
在预构建中强制排除的依赖项。
CommonJS
CommonJS 的依赖不应该排除在优化外。如果一个 ESM 依赖被排除在优化外,但是却有一个嵌套的 CommonJS 依赖,则应该为该 CommonJS 依赖添加
optimizeDeps.include
。例如:export default defineConfig({ optimizeDeps: { include: ['esm-dep > cjs-dep'] } })
- optimizeDeps.include#
-
类型:
string[]
默认情况下,不在
node_modules
中的,链接的包不会被预构建。使用此选项可强制预构建链接的包。
include
和 exclude
都可以用来处理这个问题。如果依赖项很大(包含很多内部模块)或者是 CommonJS,那么你应该包含它;如果依赖项很小,并且已经是有效的 ESM,则可以排除它,让浏览器直接加载它。
模块热重载
Vite 通过特殊的 import.meta.hot
对象暴露手动 HMR API,它包含的一些对象和方法和webpack等打包工具模块特替换的API类似。这里是官网给出的源码的简化版
interface ImportMeta {
readonly hot?: {
readonly data: any
accept(): void
accept(cb: (mod: any) => void): void
accept(dep: string, cb: (mod: any) => void): void
accept(deps: string[], cb: (mods: any[]) => void): void
prune(cb: () => void): void
dispose(cb: (data: any) => void): void
decline(): void
invalidate(): void
on(event: string, cb: (...args: any[]) => void): void
}
}
我们来看,它定义了一个 接口 ImportMeta,里面包含了只读属性hot,它里面包含的内容就是我们的HMR API了。
我们注意来看:
- data这个 对象在同一个更新模块的不同实例之间持久化。它可以用于将信息从模块的前一个版本传递到下一个版本。
- accept这个方法表示模块变化的时候执行的回调,这个方法可以有四种使用方式:
我们可以不传参数 accept()
或者是传递一个回调函数accept(cb)
以及定义依赖的模块,在回调函数里做一些事情accept(dep,cb)
我们也可以定义依赖的一系列的模块,在回调函数里对这些一系列的模块进行处理accept(deps,cb)
- prune这个方法表示在移除模块移除的时候执行回调
- disponse一个接收自身的模块或一个期望被其他模块接收的模块可以使用
hot.dispose
来清除任何由其更新副本产生的持久副作用,这个跟webpack的disponse是一个道理 - decline这个方法 表示此模块不可热更新,如果在传播 HMR 更新时遇到此模块,浏览器应该执行完全重新加载。
- invalidate现在执行时只是重新加载页面。
- on表示可以监听自定义 HMR 事件
一般的普通用户很少使用这些api。因为vite内置了HMR到常用框架的一个集成。接下来举个例子,看如何手动的使用这些api做一些事。
$ pnpm create vite # 输入项目名称 vite-basics # 选择框架 vanilla # 选择 vanilla # 出现如下界面,说明项目创建成功 # Done. Now run: # cd vite-basics # pnpm install # pnpm run dev $ cd vite-basics $ pnpm install $ pnpm run dev
我们启动一下项目,在当前的浏览器上打开一下,按住Ctrl
点击鼠标左键,打开以后打开检查,在终端上我们看到这个项目正常的启动起来
那么接下来我们就在这个项目下面构建我们所谓的HRM的例子。
我们先观察一下纯js的环境,这里有一个js文件叫main.js
import './style.css'
document.querySelector('#app').innerHTML = `
<h1>Hello Vite!</h1>
<a href="https://vitejs.dev/guide/features.html" target="_blank">Documentation</a>
`
这个文件除了引入一个css以外,就是一个纯的HTML代码了,在页面上显示了h1和一个链接。下面我们可以把这个代码先删掉保存一下。这个页面显示 page reload,也就是页面重新刷新了一下。
接下来我们打算在 vite-basics里创建一个新的模块,取名为count.js
export const count = 1
我们给这个模块定义一个变量,通过export 暴露,常量定义为count,初始值定义为1,那么接下来就观察我们的count.js自己,当这个模块发生变化时,我们可以实现页面的热重载,也就是页面不需要重新刷新,只是局部进行更新。
那么这里就需要用到HRM API了,有一点大家要格外注意,就是所谓的模块热重载(HMR),只在开发环境中使用,生产环境里我们并不需要这个热重载。为了让我们写的这个HMR的代码在生产环境里被删掉,我们得需要加一个条件守卫,这个条件守卫可以通过编译生产环境的时候,tree shaking会帮助我们进行优化。那么这个条件守卫我们该怎么写呢。
export const count = 1
if (import.meta.hot) {
}
我们写一个if判断,在判断条件里边,我们判断一个对象是否存在,就是import.meta.hot
,在开发环境里,这个变量是存在的,在生产环境里边它是不存在的。所以它就会被tree shaking给优化掉。
accept(deps,cb)监
测我们某个模块的变化
那接下来我们的HMR代码就写在if语句体里面。我们写什么呢?下面我们先实现一个监测模块自身的变化。所以呢我们先去调用到import.meta.hot.accept()
这个方法。这个方法呢就可以监测我们某个模块的变化。这里我们监测模块本身,所以呢我们只需要传递一个回调函数就可以了。
export const count = 1
if (import.meta.hot) {
import.meta.hot.accept((newModule) => {
console.log(newModule.count)
})
}
我们可以定义一个箭头函数,在这函数的参数里边,我们需要拿到被更新以后的模块的引用,我们定义一个名字叫newModule,那接下来我们在这个方法体里头去打印我们新的module的一个count,由于我们是监测这个模块本身,所以我们能拿到这个模块里边定义的敞亮count,我们保存一下,接下来我们使用一下这个模块,
// main.js
import './count.js'
打开main.js,我们引用一下count.js,可以直接通过import
导入count.js,保存一下,那么下面这个页面(编辑器终端)又执行page reload
,就是重新的去刷新了一下,接下来我们是去试图修改一下count.js,我们看页面的表现,我们把1改为2,保存一下,好,大家发现浏览器控制台立刻出现了一个2这样的一个打印,同时在终端上看到了vite有个提示,叫hmr update /count.js
,说明我们这个页面是热重载的,也就是局部更新。
那其实accept除了观测我们模块自身以外,它还可以观测其他的模块,比如我们在当前项目下再创建一个新的文件,叫做foo.js,我们来通过export来去暴露一个foo这样一个函数,我们仍旧是使用箭头函数来定义,在里面呢,随便打印一个字符串,比如说foo works
// foo.js
export const foo = () => {
console.log('foo works')
}
定义好了以后,我们再到main.js中引入这个模块,我们先简单的引用一下,执行import
,然后解构出foo这个方法,然后再去执行一下 foo 这个方法,保存一下, 大家发现,仍旧是 page reload
,页面会重新刷线一下。
// main.js
import './count.js'
import { foo } from './foo.js'
foo()
接下来我们打算去修改foo.js,看看这个模块能不能被侦测到,那我们想在main.js里去侦测foo.js的变化
// main.js
import './count.js'
import { foo } from './foo.js'
foo()
if (import.meta.hot) {
import.meta.hot.accept('./foo.js', (newFoo) => {
newFoo.foo()
})
}
接下来可以在这里做一个守卫,条件仍旧是import.meta.hot
,如果这个变量存在,我们就去调用一下import.meta.hot.accept
,这回给这个函数传入两个参数,第一个参数就是我们所谓的叫依赖的模块,那它依赖哪个模块呢,我们依赖的是当前目录下的foo.js这个模块,接下来我们再去定义第二个参数,仍旧是一个回调函数,那回调函数的参数呢,仍旧是我们newFoo这个模块,当然了,在这个方法里,你就可以做任何的事情了,当这个模块二次被更新的时候,我们可以调用newFoo.foo()
,那可见newFoo就是当前模块的引用,保存一下,这页面又进行了一次page reload
,好了接下来我们去修改一下foo.js,在后面加一个!,保存一下,观察我们控制台的变化,我们发现并没有刷新页面,只是打印了foo works!
这个字符串,同时在控制台打印了hmr update ./main.js
// foo.js
export const foo = () => {
console.log('foo works!')
}
接下来我们去扩展一下accept这个api,打开main.js,在if判断语句里,再去执行import.meta.hot.accept()
,这回不给accept方法传入参数,它的语义表示当前这个组件一旦发生变化,会执行当前组件的热更新,保存一下,写一个用例,在main.js里写一个console.log('main module')
,保存一下,发现立刻执行了模块的热更新,在终端控制台打印了hmr update ./main.js
,
// main.js
import './count.js'
import { foo } from './foo.js'
foo()
console.log('main module')
if (import.meta.hot) {
import.meta.hot.accept('./foo.js', (newFoo) => {
newFoo.foo()
})
import.meta.hot.accept()
}
如果在main module
后加一个.
,大家发现又一次执行了热更新,这样的话它就能监测这个main.js发生的变化
dispose清除上次热更新缓存的副作用
接下来我们再讲解另外一个api,叫做disponse
,我们得需要去设计一些代码,我们打开foo.js,在这个代码里执行一个定时器,让他在控制台每过1s打印一个数字,一开始我们得需要定一个数字,我们可以定一个变量cache,它是一个引用类型,在这里头,去定一个amount这样的属性,后面的初始值为0,那么接下来就可以使用 setInterval
这个方法来去执行一个每隔1s来去打印一个数字,在 setInterval
里写一个函数,在函数体里执行一个cache.amount++
的操作,然后打印一下cache.amount
,然后让他每隔1s执行这个函数,好,我们保存一下
// foo.js
export const foo = () => {
console.log('foo works!')
}
let cache = {
amount: 0
}
setInterval(() => {
cache.amount++
console.log(cache.amount)
}, 1000);
这样的话,我们就可以在控制台看到每隔1s后打印一个数字的效果
接下来我们来做一个试验,来发现一个奇怪的现象,在foo works!
后再加一个!,先不保存文件,思考一下,如果修改了当前这个模块,按理说,这个模块会重新的进行模块热更新,那热更新以后的基本流程就是先把我们这个模块上一个代码给删除掉,然后再重新加载一批新的代码,这样实现一个模块的局部更新。那现在呢,我们来保存一下,我们发现的确是foo works!!
热更新了
但是你会发现两个定时器同时启动起来了,这是为什么呢?这是因为上一个定时器没有被清理掉,可见呢,定时器的操作是一个具有副作用的一个操作,那也就是说,我们在更新模块的时候,模块的上一个状态需要把它的副作用函数的一些变量给清理掉,很显然,我们的定时器函数是一个副作用函数,它没有被清理掉,所以他又启动了一个新的定时器,同时还保留了原来的定时器,那怎么办呢?
我们得需要使用dispose
这个方法来去做一个所谓的有副作用的一些状态的清理,具体怎么办呢?在这个函数的内部,得需要一个在开发环境里的一个判断,首先判断import.meta.hot
是否存在,如果存在,就调用
import.meta.hot.dispose()
,这个方法直接收一个参数,这个参数是一个回调函数,这个回调函数在什么时候运行呢,就是在我们这个模块热更新的那个瞬间,它就会把我们的上一个模块清理的那个动作给记录下来,在清理的同时我们可以做清理副作用的一些东西,那也就是在模块状态发生变化的时候,我们就可以清理我们上一个模块的状态里面的一些内容了。比方说,正好在模块热更新的时候,我们就可以清理定时器了。
但是我们得定时器加一个引用,这样的话我们才能清理他,所以我们定义一个变量timer,然后做一个判断。如果timer存在,就通过clearInterval来去清理timer,保存一下,再次刷新一下页面,然后重新的看看这个逻辑,是否正常的运行。
// foo.js
export const foo = () => {
console.log('foo works!!!!')
}
let cache = {
amount: 0
}
if (import.meta.hot) {
import.meta.hot.dispose(() => {
if (timer) {
clearInterval(timer)
}
})
}
let timer = setInterval(() => {
cache.amount++
console.log(cache.amount)
}, 1000);
我们现在呢执行了 1 2 3 4 5 6这样累加的一个效果,那么接下来呢,我们去修改一下foo works
,再添加一个!,我们发现果然 foo works!!!
更新了。然后呢,我们的定时器会重新启动,因为我们在更新模块之前,我们已经把上一个定时器给清理了。
这个工作完成以后,我们再思考一个问题,我们能不能在模块热更新的时候保留上一个amount的值,这样岂不是更好?比如说这个应用现在有一个状态,这个状态我想在热更新的时候还保留它。那我们可以做个试验。
data热更新状态的记录
比方说我们还是拿amount为例,也就是说当我第二次修改foo works
的时候,我希望它的定时器重新启动,并且会从上一个计数的开始,继续的计数,那这个功能呢得需要我们用一个data,也就是hot中的data属性来去做一些所谓的状态的记录。怎么办呢?我们可以基于现有的代码,来继续的进行编写,我们可以判断当前的hot如果存在的情况下,我们来去修改一下cache的值,我们让cache来缓存我们模块上一个状态的amount,首先给cache重新的做一个赋值,amount的值就等于 import.meta.hot.data
,这个data就是可以帮助我们缓存模块在状态发生变化的时候一个公共的变量,或者是全局的状态,那它是一个引用类型,后面呢,可以吧cache给他,也可以把amount给读出来,这样呢,就把我们当前代码里的amount赋值给了一个新的对象,这样我们相当于做了一个状态的保存。
// foo.js
export const foo = () => {
console.log('foo works!!!!')
}
let cache = {
amount: 0
}
if (import.meta.hot) {
import.meta.hot.dispose(() => {
if (timer) {
clearInterval(timer)
}
})
cache = {
amount: import.meta.hot.data.cache.amount
}
}
let timer = setInterval(() => {
cache.amount++
console.log(cache.amount)
}, 1000);
但是大家发现浏览器控制台报了一个错误,说你不能读取一个undefined的amount,说明
很显然我们一开始的时候cache是没有的,或者说至少我们还没有对它进行缓存,那么怎么办呢?
我们把import.meta.hot.data.cache
给拷贝一下,做一个二次赋值,注意这个赋值需要给大家解释一下,在原生js里,这个赋值如果是两个等于号同时赋值的话,它会把最终的值先赋值给cache,再赋给第二个变量。
// foo.js
export const foo = () => {
console.log('foo works!!!!')
}
let cache = {
amount: 0
}
if (import.meta.hot) {
import.meta.hot.dispose(() => {
if (timer) {
clearInterval(timer)
}
})
cache = import.meta.hot.data.cache = {
amount: import.meta.hot.data.cache.amount
}
}
let timer = setInterval(() => {
cache.amount++
console.log(cache.amount)
}, 1000);
那赋值完之后就没问题了吗?我们发现还是有问题。还是刚才那个问题,这是因为在第一次的时候import.meta.hot.data.cache
,是没有值的,是为什么呢?因为前者的赋值是依托于后者计算,而后者的计算还没有开始,发现cache对象是没有的。所以我们做个判断,如果cache存在,再去读取cache下的amount,不存在呢就用0来赋值,这样才能保证我们的赋值是有效的。保存一下,刷新页面,发现没有问题,计数器正常的计数了。
// foo.js
export const foo = () => {
console.log('foo works!!!!')
}
let cache = {
amount: 0
}
if (import.meta.hot) {
import.meta.hot.dispose(() => {
if (timer) {
clearInterval(timer)
}
})
cache = import.meta.hot.data.cache = {
amount: import.meta.hot.data.cache ? import.meta.hot.data.cache.amount : 0
}
}
let timer = setInterval(() => {
cache.amount++
console.log(cache.amount)
}, 1000);
接下来验证一下cache是否有效,再给foo works
添加一个!,发现果然更新了,并且计数器会从上一个数继续的计数。这样我们的hot的data属性也就理解了。
最后我们再去讲解最后两个api。
一个是invalidate,一个是decline。
invalidate 重新加载页面
接下来我们在foo的后面暴露一个变量
// foo.js
export const foo = () => {
console.log('foo works!!!!')
}
let cache = {
amount: 0
}
if (import.meta.hot) {
import.meta.hot.dispose(() => {
if (timer) {
clearInterval(timer)
}
})
cache = import.meta.hot.data.cache = {
amount: import.meta.hot.data.cache ? import.meta.hot.data.cache.amount : 0
}
}
let timer = setInterval(() => {
cache.amount++
console.log(cache.amount)
}, 1000);
export {
cache
}
打开main.js,实现一个什么功能呢?就是newFoo.foo()的方法的执行,不是当foo发生变化的时候它就执行,而是我们根据cache的amount的数,然后看他具体的细节来去决定是否热更新。把它去掉,加一个判断,判断什么呢,因为我们刚才在foo.js中暴露了一个cache这样的一个变量,所以我们就可以通过newFoo拿到,我们读取一下newFoo的所谓的cache.amount,如果这个值大于5的话,我们做件事情,如果小于5的话,我们再执行newFoo.foo()这个所谓的热更新。如果大于5的话,我就让页面刷新了,那刷新的话该怎么作呢?我们要执行import.meta.hot.invalidate
这样的一个方法,这个方法会帮助我们刷新页面,就不执行我们所谓的热更新了。
// main.js
import './count.js'
import { foo } from './foo.js'
foo()
console.log('main module.')
if (import.meta.hot) {
import.meta.hot.accept('./foo.js', (newFoo) => {
if (newFoo.cache.amount > 5) {
import.meta.hot.invalidate()
} else {
newFoo.foo()
}
})
import.meta.hot.accept()
}
我们来做一个试验,首先刷新一个页面,打开foo.js,修改一下 foo works
,再次添加一个!。如果没有大于5,是热更新的,如果大于5,再给foo works
添加一个!,保存一下,刷新了,这就说明我们在main.js加了一个判断,这个判断中 invalidate 可以帮助我们刷新整个页面。
decline表示不可热更新
除了这个所谓的 invalidate 之外,还有一个api decline,它可以帮助我们,让当前模块立刻执行刷新,不执行热替换了。accept可以执行热替换,还有一个方法就是import.meta.hot.decline
,这个方法就是让我们这个模块刷新,跟accept是矛盾的,把它给注释掉,接下来去试验一下,foo.js里再加!,当这个模块发生变化的时候,主模块肯定也发生变化,一到这里来肯定不会执行热更新了,而是页面刷新了,保存代码后发现页面果然刷新了。
这就是我们所有的api的讲解。