惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Help Net Security
Help Net Security
Recorded Future
Recorded Future
爱范儿
爱范儿
美团技术团队
博客园_首页
腾讯CDC
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
WordPress大学
WordPress大学
阮一峰的网络日志
阮一峰的网络日志
J
Java Code Geeks
T
Tailwind CSS Blog
雷峰网
雷峰网
V
V2EX
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
月光博客
月光博客
小众软件
小众软件
博客园 - 三生石上(FineUI控件)
人人都是产品经理
人人都是产品经理
酷 壳 – CoolShell
酷 壳 – CoolShell
L
LangChain Blog
量子位
Stack Overflow Blog
Stack Overflow Blog
Google DeepMind News
Google DeepMind News
博客园 - 司徒正美
V
Visual Studio Blog
罗磊的独立博客
Jina AI
Jina AI
宝玉的分享
宝玉的分享
C
CERT Recently Published Vulnerability Notes
Engineering at Meta
Engineering at Meta
S
SegmentFault 最新的问题
T
Threat Research - Cisco Blogs
F
Fortinet All Blogs
T
Tenable Blog
S
Securelist
D
DataBreaches.Net
P
Proofpoint News Feed
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
H
Heimdal Security Blog
T
The Exploit Database - CXSecurity.com
S
Schneier on Security
Martin Fowler
Martin Fowler
W
WeLiveSecurity
MyScale Blog
MyScale Blog
The Cloudflare Blog
Security Latest
Security Latest
Hugging Face - Blog
Hugging Face - Blog
Scott Helme
Scott Helme
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
Cloudbric
Cloudbric

MARKSZのBlog

一次由 pnpm 小版本差异引发的 PicGo 2.5.3 发布事故 极空间虚拟机安装 Ubuntu 24.04 踩坑记录 | MARKSZのBlog PicGo 的签名与公证 | MARKSZのBlog 写在 PicGo 即将 8 周年之际 Typora 支持 PicGo 来上传图片了 | MARKSZのBlog 用setTimeout和clearTimeout简单实现setInterval与clearInterval | MARKSZのBlog 我的2019春招(暑期实习)记录 | MARKSZのBlog Electron-vue开发实战7——命令行调用与系统级别右键菜单的实现 | MARKSZのBlog Electron-vue开发实战6——开发插件系统之GUI部分 | MARKSZのBlog Electron-vue开发实战5——开发插件系统之CLI部分 | MARKSZのBlog 2018小结 | MARKSZのBlog 图床「神器」PicGo v2.0更新,插件系统终于来了 | MARKSZのBlog 一周一部好电影V【WEEK210 网络迷踪】 | MARKSZのBlog PicGo的star数破1000的心路历程 | MARKSZのBlog 小记VSCode插件amVim的改进以及插件开发 | MARKSZのBlog 基于Koa2开发微信二维码扫码支付相关流程 | MARKSZのBlog 【NOTE】观察者模式VS订阅发布模式 | MARKSZのBlog 【NOTE】进程-线程-协程 关系与区别 | MARKSZのBlog 基于Electron-vue的图床上传工具PicGo v1.5更新说明 | MARKSZのBlog
Vite 原理浅析 | MARKSZのBlog
Molunerfinn · 2020-05-03 · via MARKSZのBlog

已经好久没有写博客了。本文不说 Vue3.0 了,相信已经有很多文章在说它了。而前一段时间尤大开源的 Vite 则是一个更加吸引我的东西,它的总体思路是很不错的,早期源码的学习成本也比较低,于是就趁着假期学习一番。

本文撰写于 Vite-0.9.1 版本。

借用作者的原话:

Vite,一个基于浏览器原生 ES imports 的开发服务器。利用浏览器去解析 imports,在服务器端按需编译返回,完全跳过了打包这个概念,服务器随起随用。同时不仅有 Vue 文件支持,还搞定了热更新,而且热更新的速度不会随着模块增多而变慢。针对生产环境则可以把同一份代码用 rollup 打包。虽然现在还比较粗糙,但这个方向我觉得是有潜力的,做得好可以彻底解决改一行代码等半天热更新的问题。

注意到两个点:

  • 一个是 Vite 主要对应的场景是开发模式,原理是拦截浏览器发出的 ES imports 请求并做相应处理。(生产模式是用 rollup 打包)
  • 一个是 Vite 在开发模式下不需要打包,只需要编译浏览器发出的 HTTP 请求对应的文件即可,所以热更新速度很快。

因此,要实现上述目标,需要要求项目里只使用原生 ES imports,如果使用了 require 将失效,所以要用它完全替代掉 Webpack 就目前来说还是不太现实的。上面也说了,生产模式下的打包不是 Vite 自身提供的,因此生产模式下如果你想要用 Webpack 打包也依然是可以的。从这个角度来说,Vite 可能更像是替代了 webpack-dev-server 的一个东西。

modules 模块

Vite 的实现离不开现代浏览器原生支持的 模块功能。如下:

1
2
3
<script type="module">
import { a } from './a.js'
</script>

当声明一个 script 标签类型为 module 时,浏览器将对其内部的 import 引用发起 HTTP 请求获取模块内容。比如上述,浏览器将发起一个对 HOST/a.js 的 HTTP 请求,获取到内容之后再执行。

Vite 劫持了这些请求,并在后端进行相应的处理(比如将 Vue 文件拆分成 templatestylescript 三个部分),然后再返回给浏览器。

由于浏览器只会对用到的模块发起 HTTP 请求,所以 Vite 没必要对项目里所有的文件先打包后返回,而是只编译浏览器发起 HTTP 请求的模块即可。这里是不是有点按需加载的味道?

编译和打包的区别

看到这里,可能有些朋友不免有些疑问,编译和打包有什么区别?为什么 Vite 号称「热更新的速度不会随着模块增多而变慢」?

简单举个例子,有三个文件 a.jsb.jsc.js

1
2
3
4
5
6
7

const a = () => { ... }
export { a }


const b = () => { ... }
export { b }
1
2
3
4
5
6
7
8
9

import { a } from './a'
import { b } from './b'

const c = () => {
return a() + b()
}

export { c }

如果以 c 文件为入口,那么打包就会变成如下(结果进行了简化处理):(假定打包文件名为 bundle.js)

1
2
3
4
5
6
7
8

const a = () => { ... }
const b = () => { ... }
const c = () => {
return a() + b()
}

export { c }

值得注意的是,打包也需要有编译的步骤。

Webpack 的热更新原理简单来说就是,一旦发生某个依赖(比如上面的 a.js )改变,就将这个依赖所处的 module 的更新,并将新的 module 发送给浏览器重新执行。由于我们只打了一个 bundle.js,所以热更新的话也会重新打这个 bundle.js。试想如果依赖越来越多,就算只修改一个文件,理论上热更新的速度也会越来越慢。

而如果是像 Vite 这种只编译不打包会是什么情况呢?

只是编译的话,最终产出的依然是 a.jsb.jsc.js 三个文件,只有编译耗时。由于入口是 c.js,浏览器解析到 import { a } from './a' 时,会发起 HTTP 请求 a.js (b 同理),就算不用打包,也可以加载到所需要的代码,因此省去了合并代码的时间。

在热更新的时候,如果 a 发生了改变,只需要更新 a 以及用到 ac。由于 b 没有发生改变,所以 Vite 无需重新编译 b,可以从缓存中直接拿编译的结果。这样一来,修改一个文件 a,只会重新编译这个文件 a 以及浏览器当前用到这个文件 a 的文件,而其余文件都无需重新编译。所以理论上热更新的速度不会随着文件增加而变慢。

当然这样做有没有不好的地方?有,初始化的时候如果浏览器请求的模块过多,也会带来初始化的性能问题。不过如果你能遇到初始化过慢的这个问题,相信热更新的速度会弥补很多。当然我相信以后尤大也会解决这个问题。

Vite 运行 Web 应用的实现

上面说了这么多的铺垫,可能还不够直观,我们可以先跑一个 Vite 项目来实际看看。

按照官网的说明,可以输入如下命令(<project-name> 为自己想要的目录名即可)

1
2
3
4
$ npx create-vite-app <project-name>
$ cd <project-name>
$ npm install
$ npm run dev

如果一切都正常你将在 localhost:3000(Vite 的服务器起的端口) 看到这个界面:

并得到如下的代码结构:

1
2
3
4
5
.
├── App.vue // 页面的主要逻辑
├── index.html // 默认打开的页面以及 Vue 组件挂载
├── node_modules
└── package.json

拦截 HTTP 请求

接下来开始说一下 Vite 实现的核心——拦截浏览器对模块的请求并返回处理后的结果。

我们知道,由于是在 localhost:3000 打开的网页,所以浏览器发起的第一个请求自然是请求 localhost:3000/,这个请求发送到 Vite 后端之后经过静态资源服务器的处理,会进而请求到 /index.html,此时 Vite 就开始对这个请求做拦截和处理了。

首先,index.html 里的源码是这样的:

1
2
3
4
5
6
7
<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 内部去访问真正的模块,并将得到的内容再次做同样的处理后,返回给浏览器。

imports 替换

普通 JS import 替换

上面说的这步替换来自 src/node/serverPluginModuleRewrite.ts:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22



if (ctx.path === '/index.html') {

const html = await readBody(ctx.body)
ctx.body = html.replace(
/(<script\b[^>]*>)([\s\S]*?)<\/script>/gm,
(_, openTag, script) => {

const devFlag = hasInjectedDevFlag ? `` : devInjectionCode
hasInjectedDevFlag = true

return `${devFlag}${openTag}${rewriteImports(
script,
'/index.html',
resolver
)}</script>`
}
)

}

如果并没有在 script 标签内部直接写 import,而是用 src 的形式引用的话如下:

1
<script type="module" src="/main.js"></script>

那么就会在浏览器发起对 main.js 请求的时候进行处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

if (
ctx.response.is('js') &&

) {

const content = await readBody(ctx.body)
await initLexer

ctx.body = rewriteImports(
content,
ctx.url.replace(/(&|\?)t=\d+/, ''),
resolver,
ctx.query.t
)

rewriteCache.set(content, ctx.body)
}

替换逻辑 rewriteImports 就不展开了,用的是 es-module-lexer 来进行的语法分析获取 imports 数组,然后再做的替换。

*.vue 文件的替换

如果 import 的是 .vue 文件,将会做更进一步的替换:

原本的 App.vue 文件长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<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>

替换后长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

import { updateStyle } from "/@hmr"


const __script = {
data: () => ({ count: 0 }),
}


updateStyle("c44b8200-0", "/App.vue?type=style&index=0&t=1588490870523")
__script.__scopeId = "data-v-c44b8200"


import { render as __render } from "/App.vue?type=template&t=1588490870523&t=1588490870523"
__script.render = __render
__script.__hmrId = "/App.vue"
__script.__file = "/XXX/web/vite-test/App.vue"
export default __script

这样就把原本一个 .vue 的文件拆成了三个请求(分别对应 scriptstyletemplate) ,浏览器会先收到包含 script 逻辑的 App.vue 的响应,然后解析到 templatestyle 的路径后,会再次发起 HTTP 请求来请求对应的资源,此时 Vite 对其拦截并再次处理后返回相应的内容。

如下:

不得不说这个思路是非常巧妙的。

这一步的拆分来自 src/node/serverPluginVue.ts,核心逻辑是根据 URL 的 query 参数来做不同的处理(简化分析如下):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

if (!query.type) {
ctx.type = 'js'
ctx.body = compileSFCMain(descriptor, filePath, publicPath)
return etagCacheCheck(ctx)
}


if (query.type === 'template') {
ctx.type = 'js'
ctx.body = compileSFCTemplate(

)
return etagCacheCheck(ctx)
}


if (query.type === 'style') {
const index = Number(query.index)
const styleBlock = descriptor.styles[index]
const result = await compileSFCStyle(

)
if (query.module != null) {
ctx.type = 'js'
ctx.body = `export default ${JSON.stringify(result.modules)}`
} else {
ctx.type = 'css'
ctx.body = result.code
}
}

@modules/* 路径解析

上面只涉及到了替换的逻辑,解析的逻辑来自 src/node/serverPluginModuleResolve.ts。这一步就相对简单了,核心逻辑就是去 node_modules 里找有没有对应的模块,有的话就返回,没有的话就报 404:(省略了很多逻辑,比如对 web_modules 的处理、缓存的处理等)

1
2
3
4
5
6
7
8
9
10
11

try {
const file = resolve(root, id)
return serve(id, file, 'node_modules')
} catch (e) {
console.error(
chalk.red(`[vite] Error while resolving node_modules with id "${id}":`)
)
console.error(e)
ctx.status = 404
}

Vite 热更新的实现

上面已经说完了 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 刷新缓存重新执行的方法来达到更新的目的。

核心逻辑如下,我感觉非常清晰明了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
import { HMRRuntime } from 'vue' 

console.log('[vite] connecting...')

declare var __VUE_HMR_RUNTIME__: HMRRuntime

const socket = new WebSocket(`ws://${location.host}`)


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.`)
})
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.`)
})
break
case 'style-update':
updateStyle(id, `${path}?type=style&index=${index}&t=${timestamp}`)
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)
}
break
case 'js-update':
const update = jsUpdateMap.get(path)
if (update) {
update(timestamp)
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。核心是监听项目文件的变更,然后根据不同文件类型(目前只有 vuejs)来做不同的处理:

1
2
3
4
5
6
7
8
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 文件,检测 templatescriptstyle 的改动,如果有改动就通过 WS 服务端发起对应的热更新请求。

简单的源码分析如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
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)

const prevDescriptor = cacheEntry && cacheEntry.descriptor

if (!prevDescriptor) {

return
}


let needReload = false
let needRerender = false


if (!isEqual(descriptor.script, prevDescriptor.script)) {
needReload = true
}


if (!isEqual(descriptor.template, prevDescriptor.template)) {
needRerender = true
}

const styleId = hash_sum(publicPath)

const prevStyles = prevDescriptor.styles || []
const nextStyles = descriptor.styles || []


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
})
}
})
}


prevStyles.slice(nextStyles.length).forEach((_, i) => {
send({
type: 'style-remove',
path: publicPath,
id: `${styleId}-${i + nextStyles.length}`,
timestamp
})
})


if (needReload) {
send({
type: 'vue-reload',
path: publicPath,
timestamp
})
} else if (needRerender) {

send({
type: 'vue-rerender',
path: publicPath,
timestamp
})
}
}

对于热更新 js 文件而言,会递归地查找引用这个文件的 importer。比如是某个 Vue 文件所引用了这个 js,就会被查找出来。假如最终发现找不到引用者,则会返回 hasDeadEnd: true

1
2
3
4
5
6
7
8
const vueImporters = new Set<string>() 
const jsHotImporters = new Set<string>()
const hasDeadEnd = walkImportChain(
publicPath,
importers,
vueImporters,
jsHotImporters
)

如果 hasDeadEndtrue,则直接发送 full-reload。如果 vueImportersjsHotImporters 里查找到需要热更新的文件,则发起热更新通知:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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
})
})
}

客户端逻辑的注入

写到这里,还有一个问题是,我们在自己的代码里并没有引入 HRMclient 代码,Vite 是如何把 client 代码注入的呢?

回到上面的一张图,Vite 重写 App.vue 文件的内容并返回时:

注意这张图里的代码区第一句话 import { updateStyle } from '/@hmr',并且在左侧请求列表中也有一个对 @hmr 文件的请求。这个请求是啥呢?

可以发现,这个请求就是上面说的客户端逻辑的 client.ts 的内容。

src/node/serverPluginHmr.ts 里,有针对 @hmr 文件的解析处理:

1
2
3
4
5
6
7
8
9
10
11
12
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) {
return next()
}
debugHmr('serving hmr client')
ctx.type = 'js'
await cachedRead(ctx, hmrClientFilePath)
})

至此,热更新的整体流程已经解析完毕。

小结

这个项目最近在以惊人的速度迭代着,因此没过多久以后再回头看这篇文章,可能代码、实现已经过时。不过 Vite 的整体思路是非常棒的,在早期源码不多的情况下,能学到更贴近作者原始想法的东西,也算是很不错的收获。希望本文能给你学习 Vite 一些参考,有错误也欢迎大家指出。