是什么尤大选择放弃Webpack?——vite 原理解析

来自:掘金,作者:橙红年代
链接: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项目地址~目前这个项目貌似每天都在更新

首先克隆仓库

git clone https://github.com/vuejs/vite
cd vite && yarn

环境安装完毕后在项目下创建examples目录,新增index.htmlComp.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``

<template>
  <button @click="count++">{{ count }} times</button>
</template>

<script>
export default {
  data: () => ({ count: 0 })
}
</script>

<style scoped>
button { color: red }
</style>

然后在exmples目录下运行

../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的源码结构比较类似,通过装饰器逐步添加功能~目前只需要理清这四个插件的作用就可以了。

// vue2源码结构
initMixin(Vue)
stateMixin(Vue)
eventsMixin(Vue)
lifecycleMixin(Vue)
renderMixin(Vue)

moduleResolverMiddleware

这个中间件的作用编译index.htmlSFC等文件内容,处理相关的依赖。

比如上面的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、scriptstyle标签;在vite中,这些依赖都会被当做cssjs`文件请求的方式进行加载。

单页面组件主要包含templatescriptstyle标签,其中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"

styletemplate标签会被重写成/Comp.vue?type=xxx的形式,重新发送http请求,这个通过query参数的形式区分并加载SFC文件各个模块内容的方式,与vue-loader中通过webpackresourceQuery配置进行处理如出一辙,如果了解vue-loader运行原理的同学看到这里估计就已经恍然大悟了,之前写过一篇从vue-loader源码分析CSS-Scoped的实现,里面也介绍了vue-loader的大致原理。

回到vite,现在我们清楚了moduleResolverMiddleware的作用,主要就是重写模块路径,将SFC文件的依赖通过query参数进行区分,方便浏览器通过url加载实际模块。打开浏览器控制台,可以查看具体的文件请求

image

VuePlugin

前面提到单页面组件的templatestyle会被处理成单独的的import路径,通过query.type区分,那么当服务器接收到对应的url请求时,如何返回正确的资源内容呢?答案就在第二个插件VuePlugin中。

单页面文件的请求有个特点,都是以*.vue作为请求路径结尾,当服务器接收到这种特点的http请求,主要处理

  • 根据ctx.path确定请求具体的vue文件
  • 使用parseSFC解析该文件,获得descriptor,一个descriptor包含了这个组件的基本信息,包括templatescriptstyles等属性下面是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: []
}
  • 然后根据descriptorctx.query.type选择对应类型的方法,处理后返回ctx.body

  • type为空时表示处理script标签,使用compileSFCMain方法返回js内容

  • type为template时表示处理template标签,使用compileSFCTemplate方法返回render方法

  • type为styles时表示处理style标签,使用compileSFCStyle方法返回css文件内容

回头整理一下流程

  • 入口文件依赖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请求,对于每个请求,包括pathquery,其中path用于确定组件文件,query.type用于确定具体使用啥方法来返回响应内容

在上面这一步,很明显对于每个vue文件而言,都会发送多个http请求,然后执行查找和解析的操作是很频繁的,如果不配置缓存,服务器的性能负担比较大,koa-conditional-getkoa-etag应该就是为了解决这个问题,不过目前看起来还没有实现。

小结

至此,就完成了vite源码的基础阅读,由于本地阅读源码的主要目的是了解整个工具的实现原理和大致功能,因此并没有深入了解每个函数的实现细节,几个比较重要的方法包括rewriteImportscompileSFCMaincompileSFCTemplatecompileSFCStyleupdateStyle等均没有展示具体代码实现,主要的收获是了解了

  • 结合module script和query.type实现一套类似于vue-loader的机制,直接在服务端运行vue文件
  • 使用websocket手动实现热更新,由于时间关系这里并没有细读~

刚看见vite介绍时就觉得这会是一个非常有趣的工具,虽然还没有正式发布,耐不住去看了一下。感觉主要的作用有

  • 使用vite快速开发demo,而不必安装一大堆依赖
  • 类似于jsfiddle等在线预览vue文件,方便开发、测试和分发单文件组件

目前看来vite还缺少打包等重要特性,应该是没法替代webpack等工具的。不过感觉vite应该也不是用来替换现有开发工具的,所以后面大概也不会添加打包等功能吧~

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,968评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,601评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,220评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,416评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,425评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,144评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,432评论 3 401
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,088评论 0 261
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,586评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,028评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,137评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,783评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,343评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,333评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,559评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,595评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,901评论 2 345