今天分享一篇关于 vite 的文章。
文章推荐词:三元同学最近也对 Vue3 有所关注,不过我更加感兴趣的是尤大新设计出来的一个小工具——vite。大家都知道,webpack 打包的时候会有两个阶段: 编译和打包,但打包之后会有一个问题,就是随着模块的增多,会造成打出的 bundle 体积过大,进而会造成热更新速度明显拖慢。vite 的诞生就是为了解决这样的问题,当模块越来越多时,热更新速度并不会变慢。 当然,有一说一,这仅仅只是针对 Vue 项目开发阶段的工具,其他的场景还是需要依赖强大的 Webpack 的。vite 也并不是万能的。
另外值得一提的是,vite 也即将用于博客搭建系统 vuepress,来解决它热更新慢的问题。这是尤大的原话:
是不是迫不及待想了解这个工具的原理了呢?我最近看到了一篇比较 nice 的文章,对原理讲的比较清楚了。不过需要声明的是,现在 vite 这个项目更新的非常迅速,每天都有更新,因此,这篇文章涉及的代码会和最新的代码有些出入,不过原理和思路还是一样的,关于更多细节上的变更,小伙伴们还是直接去看源码吧。源码地址: https://github.com/vuejs/vite。
以下是文章具体内容:
本文同步在掘金博主:「橙红年代」个人博客shymean.com上,欢迎关注。掘金原文链接: https://juejin.im/post/5ea2361de51d454714428b44
前两天尤大在Vue 3.0 beta
直播中提到了一个vite
的工具,其描述是:针对Vue单页面组件的无打包开发服务器,可以直接在浏览器运行请求的vue文件,对其原理比较感兴趣,因此体验并写下了本文,主要包括vite实现原理分析和一些思考。
预备知识
vite
重度依赖module sciprt
的特性,因此需要提前做下功课,参考:JavaScript modules 模块 - MDN。
module sciprt
允许在浏览器中直接运行原生支持模块
<script type="module">
// index.js可以通过export导出模块,也可以在其中继续使用import加载其他依赖
import App from './index.js'
</script>
当遇见import依赖时,会直接发起http请求对应的模块文件。
开发环境
本文使用的版本为vite@0.3.2
,附github项目地址~目前这个项目貌似每天都在更新
首先克隆仓库
代码语言:javascript复制git clone https://github.com/vuejs/vite
cd vite && yarn
环境安装完毕后在项目下创建examples
目录,新增index.html
和Comp.vue
文件,这里直接用README.md中的例子
首先是inidex.html
<div id="app"></div>
<script type="module">
import { createApp } from 'vue'
import Comp from './Comp.vue'
createApp(Comp).mount('#app')
</script>
然后是`Comp.vue``
代码语言:javascript复制
<template>
<button @click="count ">{{ count }} times</button>
</template>
<script>
export default {
data: () => ({ count: 0 })
}
</script>
<style scoped>
button { color: red }
</style>
然后在exmples目录下运行
代码语言:javascript复制../bin/vite.js
即可在浏览器http://localhost:3000
打开预览,同时支持文件热更新哦~
如果需要调试源码,启动npm run dev
即可,会开启tsc -w --p
监听src目录的改动并实时输出到dist目录下,接下来就可以开启欢乐的源码时间~
入口文件
目前这个项目迭代非常频繁(昨天还有historyFallbackMiddleware
这个中间件呢今天貌似就没了),但是大概的实现思路应该是基本确定了,因此先确定本次源码阅读目标:了解如何在不使用webpack等打包工具的前提下直接运行vue文件。基于这个目的,主要是了解实现思路,理清整体结构,不用拘泥于具体细节。
从入口bin/vite.js
开始
const server = require('../dist/server').createServer(argv)
可以看见createServer
方法,直接定位到src/server/client.tx
。vite使用的是Koa
构建服务端,在createServer
中主要通过中间件注册相关功能
// src/index.ts
// 提前预告这四个插件的作用
const internalPlugins: Plugin[] = [
modulesPlugin, // 处理入口html文件script标签和每个vue文件的模块依赖
vuePlugin, // vue单页面组件解析,将template、script、style解析成不同的响应内容,可以理解为简易版的vue-loader
hmrPlugin, // 使用websocket实现文件热更新
servePlugin // koa配置插件,目前看来主要是配置协商缓存相关
]
export function createServer({
root = process.cwd(),
middlewares: userMiddlewares = []
}: ServerConfig = {}): Server {
const app = new Koa()
const server = http.createServer(app.callback())
// 预留了userMiddlewares方便提供后续API
;[...userMiddlewares, ...middlewares].forEach((m) =>
m({
root,
app,
server
})
)
return server
}
vite
是通过下面这种middleware
的形式注册koa中间件,
export const modulesPlugin: Plugin = ({ root, app }) => {
// 每个插件实际上是注册koa中间件
app.use(async (ctx, next) => {})
}
看起来跟Vue2的源码结构比较类似,通过装饰器逐步添加功能~目前只需要理清这四个插件的作用就可以了。
代码语言:javascript复制// vue2源码结构
initMixin(Vue)
stateMixin(Vue)
eventsMixin(Vue)
lifecycleMixin(Vue)
renderMixin(Vue)
moduleResolverMiddleware
这个中间件的作用编译index.html
和SFC
等文件内容,处理相关的依赖。
比如上面的html文件script
标签内容,通过rewriteImports
等方法的处理会被编译成
import { createApp } from '/__modules/vue'// 之前是import { createApp } from 'vue'
import Comp from './Comp.vue'
createApp(Comp).mount('#app')
这样当浏览器解析并运行这个module类型的script标签时,就会请求对应的模块文件,其中
/__modules/vue
是koa服务器的静态资源目录文件,./Comp.vue
是我们编写的单页面组件文件- 此外貌似还会提供
sourcemap
等功能
对于入口文件而言,需要script标签下相关依赖。对于单页面组件而言,在vue-loader
中,也需要处理tmplate、
script和
style标签;在vite中,这些依赖都会被当做
css和
js`文件请求的方式进行加载。
单页面组件主要包含template
、script
和style
标签,其中script
标签内代码的导出会被编译成
// 加载热更新模块客户端,后面会提到
import "/__hmrClient"
let __script; export default (__script = {
data: () => ({ count: 0 })
})
// 根据type进行区分,样式文件type=style
import "/Comp.vue?type=style&index=0"
// 保留css scopeID
__script.__scopeId = "data-v-92a6df80"
// render函数文件type=template
import { render as __render } from "/Comp.vue?type=template"
__script.render = __render
__script.__hmrId = "/Comp.vue"
而style
及template
标签会被重写成/Comp.vue?type=xxx
的形式,重新发送http请求,这个通过query参数的形式区分并加载SFC文件各个模块内容的方式,与vue-loader
中通过webpack
的resourceQuery
配置进行处理如初一辙,如果了解vue-loader
运行原理的同学看到这里估计就已经恍然大悟了,之前写过一篇从vue-loader源码分析CSS-Scoped的实现,里面也介绍了vue-loader的大致原理。
回到vite,现在我们清楚了moduleResolverMiddleware
的作用,主要就是重写模块路径,将SFC文件的依赖通过query参数进行区分,方便浏览器通过url加载实际模块。打开浏览器控制台,可以查看具体的文件请求
VuePlugin
前面提到单页面组件的template
和style
会被处理成单独的的import路径,通过query.type区分,那么当服务器接收到对应的url请求时,如何返回正确的资源内容呢?答案就在第二个插件VuePlugin
中。
单页面文件的请求有个特点,都是以*.vue
作为请求路径结尾,当服务器接收到这种特点的http请求,主要处理
- 根据
ctx.path
确定请求具体的vue文件 - 使用
parseSFC
解析该文件,获得descriptor
,一个descriptor
包含了这个组件的基本信息,包括template
、script
和styles
等属性 下面是Comp.vue
文件经过处理后获得的descriptor
{
filename: '/Users/Txm/source_code/vite/examples/Comp.vue',
template: {
type: 'template',
content: 'n <button @click="count ">{{ count }} times1</button>n',
loc: {
source: 'n <button @click="count ">{{ count }} times1</button>n',
start: [Object],
end: [Object]
},
attrs: {},
map: {
version: 3,
sources: [Array],
names: [],
mappings: ';AACA',
file: '/Users/Txm/source_code/vite/examples/Comp.vue',
sourceRoot: '',
sourcesContent: [Array]
}
},
script: {
type: 'script',
content: 'nexport default {n data: () => ({ count: 0 })n}n',
loc: {
source: 'nexport default {n data: () => ({ count: 0 })n}n',
start: [Object],
end: [Object]
},
attrs: {},
map: {
version: 3,
sources: [Array],
names: [],
mappings: ';AAKA;AACA;AACA',
file: '/Users/Txm/source_code/vite/examples/Comp.vue',
sourceRoot: '',
sourcesContent: [Array]
}
},
styles: [
{
type: 'style',
content: 'nbutton { color: red }n',
loc: [Object],
attrs: [Object],
scoped: true,
map: [Object]
}
],
customBlocks: []
}
- 然后根据
descriptor
和ctx.query.type
选择对应类型的方法,处理后返回ctx.body
- type为空时表示处理
script
标签,使用compileSFCMain
方法返回js
内容 - type为
template
时表示处理template
标签,使用compileSFCTemplate
方法返回render
方法 - type为
style
s时表示处理style
标签,使用compileSFCStyle
方法返回css
文件内容
- type为空时表示处理
回头整理一下流程
- 入口文件依赖
Comp.vue
的script代码 Com.vue
依赖tempplate
编译的render方法,依赖style
标签编译的css代码,这两个文件放在script
的编译代码中进行依赖声明
// Comp.vue返回的文件内容,可以看见跟入口文件的script标签内容比较相似
import { updateStyle } from "/__hmrClient"
const __script = {
data: () => ({ count: 0 })
}
// style标签内容解析后的css代码
updateStyle("92a6df80-0", "/Comp.vue?type=style&index=0")
__script.__scopeId = "data-v-92a6df80"
// temlpate标签内容解析后的render
import { render as __render } from "/Comp.vue?type=template"
__script.render = __render
__script.__hmrId = "/Comp.vue"
export default __script
每个标签内容解析完成之后,会通过LRUCache
缓存起来,方便下次重复使用
export const vueCache = new LRUCache<string, CacheEntry>({
max: 65535
})
至此,我们就大致了解了vite
是如何通过koa直接运行vue文件的,其思路跟vue-loader
比较类似,借助module script
处理文件依赖,然后通过拼接不同的query.type
处理单页面文件解析后的各个资源文件,最后响应给浏览器进行渲染。
hmrPlugin
前面提到vite
也是支持文件热更新的,既然没有使用webpack
,那该是如何做到的呢?答案就是自己实现一个哈哈哈~
热更新主要通过webSocket
实现,包括ws服务端和ws客户端两个部分,hmrPlugin
主要负责ws服务端的部分,ws客户端在src/client.ts
中实现,并通过在第一步处理模块依赖时import "/__hmrClient"
将服务端和客户端关联起来。
目前主要定义了下面几种消息类型
reload
rerender
style-update
style-remove
full-reload
当文件发生变化时,服务端在handleVueSFCReload
方法中会根据变化的类型推送不同的消息,当客户端接收到对应消息时,会结合vue.HMRRuntime
进行处理或者重新加载新的资源。
热更新这里目前还有不少TODO
,感觉是一个学习热更新原理的不错案例,先码一下后面回头重新细读。
关于热更新的原理,社区有不少原理分析了,不妨移步阅读
- Webpack 热更新
- 轻松理解webpack热更新原理
servePlugin
这个插件主要用于实现一些koa请求和响应的配置。
经过上面的分析,每次请求时,都会从入口文件开始,依次分析每个依赖
- 对于普通文件,直接查找服务器静态资源, 通过
servePlugin
中配置koa-static
实现 - 对于vue文件,会重新拼接http请求,对于每个请求,包括
path
和query
,其中path用于确定组件文件,query.type
用于确定具体使用啥方法来返回响应内容
在上面这一步,很明显对于每个vue文件而言,都会发送多个http请求,然后执行查找和解析的操作是很频繁的,如果不配置缓存,服务器的性能负担比较大,koa-conditional-get
和koa-etag
应该就是为了解决这个问题,不过目前看起来还没有实现。
小结
至此,就完成了vite
源码的基础阅读,由于本地阅读源码的主要目的是了解整个工具的实现原理和大致功能,因此并没有深入了解每个函数的实现细节,几个比较重要的方法包括rewriteImports
、compileSFCMain
、compileSFCTemplate
、compileSFCStyle
、updateStyle
等均没有展示具体代码实现,主要的收获是了解了
- 结合module script和query.type实现一套类似于vue-loader的机制,直接在服务端运行vue文件
- 使用websocket手动实现热更新,由于时间关系这里并没有细读~
刚看见vite介绍时就觉得这会是一个非常有趣的工具,虽然还没有正式发布,耐不住去看了一下。感觉主要的作用有
- 使用vite快速开发demo,而不必安装一大堆依赖
- 类似于
jsfiddle
等在线预览vue文件,方便开发、测试和分发单文件组件
目前看来vite还缺少打包等重要特性,应该是没法替代webpack等工具的。不过感觉vite应该也不是用来替换现有开发工具的,所以后面大概也不会添加打包等功能吧~