原文首发在个人博客,欢迎访问。javascript
已经很久没有写博客了。本文不说 Vue3.0 了,相信已经有不少文章在说它了。而前一段时间尤大开源的 Vite 则是一个更加吸引个人东西,它的整体思路是很不错的,早期源码的学习成本也比较低,因而就趁着假期学习一番。css
本文撰写于 Vite-0.9.1 版本。html
借用做者的原话:vue
Vite,一个基于浏览器原生 ES imports 的开发服务器。利用浏览器去解析 imports,在服务器端按需编译返回,彻底跳过了打包这个概念,服务器随起随用。同时不只有 Vue 文件支持,还搞定了热更新,并且热更新的速度不会随着模块增多而变慢。针对生产环境则能够把同一份代码用 rollup 打包。虽然如今还比较粗糙,但这个方向我以为是有潜力的,作得好能够完全解决改一行代码等半天热更新的问题。java
注意到两个点:node
所以,要实现上述目标,须要要求项目里只使用原生 ES imports,若是使用了 require 将失效,因此要用它彻底替代掉 Webpack 就目前来讲仍是不太现实的。上面也说了,生产模式下的打包不是 Vite 自身提供的,所以生产模式下若是你想要用 Webpack 打包也依然是能够的。从这个角度来讲,Vite 可能更像是替代了 webpack-dev-server 的一个东西。webpack
Vite 的实现离不开现代浏览器原生支持的 模块功能。以下:ios
<script type="module"> import { a } from './a.js' </script>
复制代码
当声明一个 script
标签类型为 module
时,浏览器将对其内部的 import
引用发起 HTTP
请求获取模块内容。好比上述,浏览器将发起一个对 HOST/a.js
的 HTTP 请求,获取到内容以后再执行。git
Vite 劫持了这些请求,并在后端进行相应的处理(好比将 Vue 文件拆分红 template
、style
、script
三个部分),而后再返回给浏览器。github
因为浏览器只会对用到的模块发起 HTTP 请求,因此 Vite 不必对项目里全部的文件先打包后返回,而是只编译浏览器发起 HTTP 请求的模块便可。这里是否是有点按需加载的味道?
看到这里,可能有些朋友难免有些疑问,编译和打包有什么区别?为何 Vite 号称「热更新的速度不会随着模块增多而变慢」?
简单举个例子,有三个文件 a.js
、b.js
、c.js
// a.js
const a = () => { ... }
export { a }
// b.js
const b = () => { ... }
export { b }
复制代码
// c.js
import { a } from './a'
import { b } from './b'
const c = () => {
return a() + b()
}
export { c }
复制代码
若是以 c 文件为入口,那么打包就会变成以下(结果进行了简化处理):(假定打包文件名为 bundle.js
)
// bundle.js
const a = () => { ... }
const b = () => { ... }
const c = () => {
return a() + b()
}
export { c }
复制代码
值得注意的是,打包也须要有编译的步骤。
Webpack 的热更新原理简单来讲就是,一旦发生某个依赖(好比上面的 a.js
)改变,就将这个依赖所处的 module
的更新,并将新的 module
发送给浏览器从新执行。因为咱们只打了一个 bundle.js
,因此热更新的话也会从新打这个 bundle.js
。试想若是依赖愈来愈多,就算只修改一个文件,理论上热更新的速度也会愈来愈慢。
而若是是像 Vite 这种只编译不打包会是什么状况呢?
只是编译的话,最终产出的依然是 a.js
、b.js
、c.js
三个文件,只有编译耗时。因为入口是 c.js
,浏览器解析到 import { a } from './a'
时,会发起 HTTP 请求 a.js
(b 同理),就算不用打包,也能够加载到所须要的代码,所以省去了合并代码的时间。
在热更新的时候,若是 a
发生了改变,只须要更新 a
以及用到 a
的 c
。因为 b
没有发生改变,因此 Vite 无需从新编译 b
,能够从缓存中直接拿编译的结果。这样一来,修改一个文件 a
,只会从新编译这个文件 a
以及浏览器当前用到这个文件 a
的文件,而其他文件都无需从新编译。因此理论上热更新的速度不会随着文件增长而变慢。
固然这样作有没有很差的地方?有,初始化的时候若是浏览器请求的模块过多,也会带来初始化的性能问题。不过若是你能遇到初始化过慢的这个问题,相信热更新的速度会弥补不少。固然我相信之后尤大也会解决这个问题。
上面说了这么多的铺垫,可能还不够直观,咱们能够先跑一个 Vite 项目来实际看看。
按照官网的说明,能够输入以下命令(<project-name>
为本身想要的目录名便可)
$ npx create-vite-app <project-name>
$ cd <project-name>
$ npm install
$ npm run dev
复制代码
若是一切都正常你将在 localhost:3000
(Vite 的服务器起的端口) 看到这个界面:
并获得以下的代码结构:
.
├── App.vue // 页面的主要逻辑
├── index.html // 默认打开的页面以及 Vue 组件挂载
├── node_modules
└── package.json
复制代码
接下来开始说一下 Vite 实现的核心——拦截浏览器对模块的请求并返回处理后的结果。
咱们知道,因为是在 localhost:3000
打开的网页,因此浏览器发起的第一个请求天然是请求 localhost:3000/
,这个请求发送到 Vite 后端以后通过静态资源服务器的处理,会进而请求到 /index.html
,此时 Vite 就开始对这个请求作拦截和处理了。
首先,index.html
里的源码是这样的:
<div id="app"></div>
<script type="module"> import { createApp } from 'vue' import App from './App.vue' createApp(App).mount('#app') </script>
复制代码
可是在浏览器里它是这样的:
注意到什么不一样了吗?是的, import { createApp } from 'vue'
换成了 import { createApp } from '/@modules/vue
。
这里就不得不说浏览器对 import
的模块发起请求时的一些局限了,平时咱们写代码,若是不是引用相对路径的模块,而是引用 node_modules
的模块,都是直接 import xxx from 'xxx'
,由 Webpack 等工具来帮咱们找这个模块的具体路径。可是浏览器不知道你项目里有 node_modules
,它只能经过相对路径去寻找模块。
所以 Vite 在拦截的请求里,对直接引用 node_modules
的模块都作了路径的替换,换成了 /@modules/
并返回回去。然后浏览器收到后,会发起对 /@modules/xxx
的请求,而后被 Vite 再次拦截,并由 Vite 内部去访问真正的模块,并将获得的内容再次作一样的处理后,返回给浏览器。
上面说的这步替换来自 src/node/serverPluginModuleRewrite.ts
:
// 只取关键代码:
// Vite 使用 Koa 做为内置的服务器
// 若是请求的路径是 /index.html
if (ctx.path === '/index.html') {
// ...
const html = await readBody(ctx.body)
ctx.body = html.replace(
/(<script\b[^>]*>)([\s\S]*?)<\/script>/gm, // 正则匹配
(_, openTag, script) => {
// also inject __DEV__ flag
const devFlag = hasInjectedDevFlag ? `` : devInjectionCode
hasInjectedDevFlag = true
// 替换 html 的 import 路径
return `${devFlag}${openTag}${rewriteImports( script, '/index.html', resolver )}</script>`
}
)
// ...
}
复制代码
若是并无在 script
标签内部直接写 import
,而是用 src
的形式引用的话以下:
<script type="module" src="/main.js"></script>
复制代码
那么就会在浏览器发起对 main.js
请求的时候进行处理:
// 只取关键代码:
if (
ctx.response.is('js') &&
// ...
) {
// ...
const content = await readBody(ctx.body)
await initLexer
// 重写 js 文件里的 import
ctx.body = rewriteImports(
content,
ctx.url.replace(/(&|\?)t=\d+/, ''),
resolver,
ctx.query.t
)
// 写入缓存,以后能够从缓存中直接读取
rewriteCache.set(content, ctx.body)
}
复制代码
替换逻辑 rewriteImports
就不展开了,用的是 es-module-lexer
来进行的语法分析获取 imports
数组,而后再作的替换。
若是 import
的是 .vue
文件,将会作更进一步的替换:
本来的 App.vue
文件长这样:
<template>
<h1>Hello Vite + Vue 3!</h1>
<p>Edit ./App.vue to test hot module replacement (HMR).</p>
<p>
<span>Count is: {{ count }}</span>
<button @click="count++">increment</button>
</p>
</template>
<script> export default { data: () => ({ count: 0 }), } </script>
<style scoped> h1 { color: #4fc08d; } h1, p { font-family: Arial, Helvetica, sans-serif; } </style>
复制代码
替换后长这样:
// localhost:3000/App.vue
import { updateStyle } from "/@hmr"
// 抽出 script 逻辑
const __script = {
data: () => ({ count: 0 }),
}
// 将 style 拆分红 /App.vue?type=style 请求,由浏览器继续发起请求获取样式
updateStyle("c44b8200-0", "/App.vue?type=style&index=0&t=1588490870523")
__script.__scopeId = "data-v-c44b8200" // 样式的 scopeId
// 将 template 拆分红 /App.vue?type=template 请求,由浏览器继续发起请求获取 render function
import { render as __render } from "/App.vue?type=template&t=1588490870523&t=1588490870523"
__script.render = __render // render 方法挂载,用于 createApp 时渲染
__script.__hmrId = "/App.vue" // 记录 HMR 的 id,用于热更新
__script.__file = "/XXX/web/vite-test/App.vue" // 记录文件的原始的路径,后续热更新能用到
export default __script
复制代码
这样就把本来一个 .vue
的文件拆成了三个请求(分别对应 script
、style
和template
) ,浏览器会先收到包含 script
逻辑的 App.vue
的响应,而后解析到 template
和 style
的路径后,会再次发起 HTTP 请求来请求对应的资源,此时 Vite 对其拦截并再次处理后返回相应的内容。
以下:
不得不说这个思路是很是巧妙的。
这一步的拆分来自 src/node/serverPluginVue.ts
,核心逻辑是根据 URL 的 query 参数来作不一样的处理(简化分析以下):
// 若是没有 query 的 type,好比直接请求的 /App.vue
if (!query.type) {
ctx.type = 'js'
ctx.body = compileSFCMain(descriptor, filePath, publicPath) // 编译 App.vue,编译成上面说的带有 script 内容,以及 template 和 style 连接的形式。
return etagCacheCheck(ctx) // ETAG 缓存检测相关逻辑
}
// 若是 query 的 type 是 template,好比 /App.vue?type=template&xxx
if (query.type === 'template') {
ctx.type = 'js'
ctx.body = compileSFCTemplate( // 编译 template 生成 render function
// ...
)
return etagCacheCheck(ctx)
}
// 若是 query 的 type 是 style,好比 /App.vue?type=style&xxx
if (query.type === 'style') {
const index = Number(query.index)
const styleBlock = descriptor.styles[index]
const result = await compileSFCStyle( // 编译 style
// ...
)
if (query.module != null) { // 若是是 css module
ctx.type = 'js'
ctx.body = `export default ${JSON.stringify(result.modules)}`
} else { // 正常 css
ctx.type = 'css'
ctx.body = result.code
}
}
复制代码
上面只涉及到了替换的逻辑,解析的逻辑来自 src/node/serverPluginModuleResolve.ts
。这一步就相对简单了,核心逻辑就是去 node_modules
里找有没有对应的模块,有的话就返回,没有的话就报 404:(省略了不少逻辑,好比对 web_modules
的处理、缓存的处理等)
// ...
try {
const file = resolve(root, id) // id 是模块的名字,好比 axios
return serve(id, file, 'node_modules') // 从 node_modules 中找到真正的模块内容并返回
} catch (e) {
console.error(
chalk.red(`[vite] Error while resolving node_modules with id "${id}":`)
)
console.error(e)
ctx.status = 404 // 若是没找到就 404
}
复制代码
上面已经说完了 Vite 是如何运行一个 Web 应用的,包括如何拦截请求、替换内容、返回处理后的结果。接下来讲一下 Vite 热更新的实现,一样实现的很是巧妙。
咱们知道,若是要实现热更新,那么就须要浏览器和服务器创建某种通讯机制,这样浏览器才能收到通知进行热更新。Vite 的是经过 WebSocket
来实现的热更新通讯。
客户端的代码在 src/client/client.ts
,主要是建立 WebSocket
客户端,监听来自服务端的 HMR 消息推送。
Vite 的 WS 客户端目前监听这几种消息:
connected
: WebSocket 链接成功vue-reload
: Vue 组件从新加载(当你修改了 script 里的内容时)vue-rerender
: Vue 组件从新渲染(当你修改了 template 里的内容时)style-update
: 样式更新style-remove
: 样式移除js-update
: js 文件更新full-reload
: fallback 机制,网页重刷新其中针对 Vue 组件自己的一些更新,均可以直接调用 HMRRuntime
提供的方法,很是方便。其他的更新逻辑,基本上都是利用了 timestamp
刷新缓存从新执行的方法来达到更新的目的。
核心逻辑以下,我感受很是清晰明了:
import { HMRRuntime } from 'vue' // 来自 Vue3.0 的 HMRRuntime
console.log('[vite] connecting...')
declare var __VUE_HMR_RUNTIME__: HMRRuntime
const socket = new WebSocket(`ws://${location.host}`)
// Listen for messages
socket.addEventListener('message', ({ data }) => {
const { type, path, id, index, timestamp, customData } = JSON.parse(data)
switch (type) {
case 'connected':
console.log(`[vite] connected.`)
break
case 'vue-reload':
import(`${path}?t=${timestamp}`).then((m) => {
__VUE_HMR_RUNTIME__.reload(path, m.default)
console.log(`[vite] ${path} reloaded.`) // 调用 HMRRUNTIME 的方法更新
})
break
case 'vue-rerender':
import(`${path}?type=template&t=${timestamp}`).then((m) => {
__VUE_HMR_RUNTIME__.rerender(path, m.render)
console.log(`[vite] ${path} template updated.`) // 调用 HMRRUNTIME 的方法更新
})
break
case 'style-update':
updateStyle(id, `${path}?type=style&index=${index}&t=${timestamp}`) // 从新加载 style 的 URL
console.log(
`[vite] ${path} style${index > 0 ? `#${index}` : ``} updated.`
)
break
case 'style-remove':
const link = document.getElementById(`vite-css-${id}`)
if (link) {
document.head.removeChild(link) // 删除 style
}
break
case 'js-update':
const update = jsUpdateMap.get(path)
if (update) {
update(timestamp) // 用新的时间戳加载并执行 js,达到更新的目的
console.log(`[vite]: js module reloaded: `, path)
} else {
console.error(
`[vite] got js update notification but no client callback was registered. Something is wrong.`
)
}
break
case 'custom':
const cbs = customUpdateMap.get(id)
if (cbs) {
cbs.forEach((cb) => cb(customData))
}
break
case 'full-reload':
location.reload()
}
})
复制代码
服务端的实现位于 src/node/serverPluginHmr.ts
。核心是监听项目文件的变动,而后根据不一样文件类型(目前只有 vue
和 js
)来作不一样的处理:
watcher.on('change', async (file) => {
const timestamp = Date.now() // 更新时间戳
if (file.endsWith('.vue')) {
handleVueReload(file, timestamp)
} else if (file.endsWith('.js')) {
handleJSReload(file, timestamp)
}
})
复制代码
对于 Vue
文件的热更新而言,主要是从新编译 Vue
文件,检测 template
、script
、style
的改动,若是有改动就经过 WS 服务端发起对应的热更新请求。
简单的源码分析以下:
async function handleVueReload( file: string, timestamp: number = Date.now(), content?: string ) {
const publicPath = resolver.fileToRequest(file) // 获取文件的路径
const cacheEntry = vueCache.get(file) // 获取缓存里的内容
debugHmr(`busting Vue cache for ${file}`)
vueCache.del(file) // 发生变更了所以以前的缓存能够删除
const descriptor = await parseSFC(root, file, content) // 编译 Vue 文件
const prevDescriptor = cacheEntry && cacheEntry.descriptor // 获取前一次的缓存
if (!prevDescriptor) {
// 这个文件以前从未被访问过(本次是第一次访问),也就不必热更新
return
}
// 设置两个标志位,用于判断是须要 reload 仍是 rerender
let needReload = false
let needRerender = false
// 若是 script 部分不一样则须要 reload
if (!isEqual(descriptor.script, prevDescriptor.script)) {
needReload = true
}
// 若是 template 部分不一样则须要 rerender
if (!isEqual(descriptor.template, prevDescriptor.template)) {
needRerender = true
}
const styleId = hash_sum(publicPath)
// 获取以前的 style 以及下一次(或者说热更新)的 style
const prevStyles = prevDescriptor.styles || []
const nextStyles = descriptor.styles || []
// 若是不须要 reload,则查看是否须要更新 style
if (!needReload) {
nextStyles.forEach((_, i) => {
if (!prevStyles[i] || !isEqual(prevStyles[i], nextStyles[i])) {
send({
type: 'style-update',
path: publicPath,
index: i,
id: `${styleId}-${i}`,
timestamp
})
}
})
}
// 若是 style 标签及内容删掉了,则须要发送 `style-remove` 的通知
prevStyles.slice(nextStyles.length).forEach((_, i) => {
send({
type: 'style-remove',
path: publicPath,
id: `${styleId}-${i + nextStyles.length}`,
timestamp
})
})
// 若是须要 reload 发送 `vue-reload` 通知
if (needReload) {
send({
type: 'vue-reload',
path: publicPath,
timestamp
})
} else if (needRerender) {
// 不然发送 `vue-rerender` 通知
send({
type: 'vue-rerender',
path: publicPath,
timestamp
})
}
}
复制代码
对于热更新 js
文件而言,会递归地查找引用这个文件的 importer
。好比是某个 Vue
文件所引用了这个 js
,就会被查找出来。假如最终发现找不到引用者,则会返回 hasDeadEnd: true
。
const vueImporters = new Set<string>() // 查找并存放须要热更新的 Vue 文件
const jsHotImporters = new Set<string>() // 查找并存放须要热更新的 js 文件
const hasDeadEnd = walkImportChain(
publicPath,
importers,
vueImporters,
jsHotImporters
)
复制代码
若是 hasDeadEnd
为 true
,则直接发送 full-reload
。若是 vueImporters
或 jsHotImporters
里查找到须要热更新的文件,则发起热更新通知:
if (hasDeadEnd) {
send({
type: 'full-reload',
timestamp
})
} else {
vueImporters.forEach((vueImporter) => {
send({
type: 'vue-reload',
path: vueImporter,
timestamp
})
})
jsHotImporters.forEach((jsImporter) => {
send({
type: 'js-update',
path: jsImporter,
timestamp
})
})
}
复制代码
写到这里,还有一个问题是,咱们在本身的代码里并无引入 HRM
的 client
代码,Vite 是如何把 client
代码注入的呢?
回到上面的一张图,Vite 重写 App.vue
文件的内容并返回时:
注意这张图里的代码区第一句话 import { updateStyle } from '/@hmr'
,而且在左侧请求列表中也有一个对 @hmr
文件的请求。这个请求是啥呢?
能够发现,这个请求就是上面说的客户端逻辑的 client.ts
的内容。
在 src/node/serverPluginHmr.ts
里,有针对 @hmr
文件的解析处理:
export const hmrClientFilePath = path.resolve(__dirname, './client.js')
export const hmrClientId = '@hmr'
export const hmrClientPublicPath = `/${hmrClientId}`
app.use(async (ctx, next) => {
if (ctx.path !== hmrClientPublicPath) { // 请求路径若是不是 @hmr 就跳过
return next()
}
debugHmr('serving hmr client')
ctx.type = 'js'
await cachedRead(ctx, hmrClientFilePath) // 返回 client.js 的内容
})
复制代码
至此,热更新的总体流程已经解析完毕。
这个项目最近在以惊人的速度迭代着,所以没过多久之后再回头看这篇文章,可能代码、实现已通过时。不过 Vite 的总体思路是很是棒的,在早期源码很少的状况下,能学到更贴近做者原始想法的东西,也算是很不错的收获。但愿本文能给你学习 Vite 一些参考,有错误也欢迎你们指出。