.vue文件解析:从dev-server到浏览器都发生了什么?

今天的文章主要是探讨一下vite中对于.vue文件的处理,可能有小伙伴想要查阅webpack相关的资料可能要失望了。不过vite作为vue官方的脚手架已经有一段时间,作为nobundle开发工具给开发者提供了很好的体验。

但是做开发的小伙伴们可能已经做了不少的表单同步,增删改查,但对于开发服务器是怎么一回事可能不是特别了解

所以这边文章就深入了解一下,开发环境下.vue文件是怎么到浏览器中执行的,顺带还会讲解一下vite的nobundle原理以及和我们开发体验强相关的热更新。热更新失效的痛苦估计每个小伙伴都体验过,改一下代码刷新下页面。所以了解热更新如何实现还是很有必要的

如果我们用npx create命令初始化一个新项目,你们也可以自己npm create vite一个新项目自己试试,应该和我这个是一样的,刷新一下可以在devtool中的请求面板看到这些东西

空项目目录结构大概是这样, 只有两个组件App和HelloWorld,:

我们第一个开始看,也就是对127.0.0.1服务器根目录的请求,服务器根目录也就是我们项目的根目录:

其实就是我们根文件夹下的HTML文件,但是又好像多了点什么,我们可以对比一下 发现区别了没有?多了一行代码:

xml 复制代码
 <script type="module" src="/@vite/client"></script>

因为这行代码的存在,我们的浏览器,第二个去请求的文件就变成了src中的/@vite/client,因为他是浏览器解析到的第一个脚本嘛

我们知道vite是有热更新机制的,开发阶段,我们变更了代码之后,不需要刷新,更改就可以直接显示出来,所以vite需要一段运行时来做这个事,也就是import.meta.hot这个API,我们知道import.meta是ES模块的特性,比如import.meta.url可以获取模块的绝对路径。而import.meta.hot是vite的运行时注入的,如果我们看一下vue文件编译后的代码:

就会发现每一个vue文件的顶部都会被vite拼接上这样一段代码,createHotConntext就是从我们前面提到的运行时@vite/client中引入的,这个函数接受vue文件在开发服务器根目录(默认也就是'/',即项目的根目录)的绝对路径为参数,他的返回值就是import.meta.hot。因为浏览器一开始请求了/@vite/client,所以后面的再去import时就可以找到这段运行时了

这里简单说一下热更新的原理:我们平时使用热更新api时,要用import.hot.accept(cb),来注册热更新回调,回调拿到的就是热更新后的模块导出内容。从上面的代码我们可以看到createHotContext的参数是当前文件的路径,vite浏览器运行时会根据key和accpet接受的回调创建一个map,热更新发生时,找到这个key,也就是路径,然后将热更新拿到的模块内容传给回调。

言归正传,我们是要看vue文件是怎么处理的,不妨看一下编译后的vue文件,这里以helloWorld组件为例,相信使用过vite的小伙伴想在devtool中看一下自己的代码时都是懵逼的:

组件选项对象:

我用方框分开了几个模块,首先看第一个模块,是一个对象:sfc_main。聪明的小伙伴一下就能看出来,这不就是组件选项吗,因为vite默认推荐使用scriptSetup组织模块代码,所以可以看见其实最终还是把我们在scriptSetup代码块中的代码给拼起来拼成一个只使用setup和props的组件选项对象,同时把顶层的变量作为setup()返回值返回,但是这里还有一句代码:

php 复制代码
Object.defineProperty(__returned__, '__isScriptSetup', { enumerable: false, value: true })

defineProperty我就不再赘述,可以知道最终是在setup()的返回值中除了我们的顶层变量之外,还加入了一个__isScriptSetup值为true。

要说清楚这个有什么用还要说一下setup这个api,vue3中的setup有两种用法,一种是将setup作为一个组件选项,它的返回值会暴露给模版使用,在这种情况下,我们代码里肯定都是用this去访问组件的上下文信息的,比如可以使用this.data访问数据选项。所以这种情况下就不能使用开头的变量,以防止出现命名空间冲突,在这种情况下vue内部会做一个判断,如果没有__isScriptSetup而且setup返回值以$开头,那么就会报错了。

但是scriptSetup的方式写代码就没这个问题,因为我们不会用this去访问$开头的vue内部属性,有了__isScriptSetup就可以跳过上面说的命名空间检查了。

render函数:

来看下第二个模块,对vue有一定了解的小伙伴都知到,vue模版最后被编译成了render函数,那么很显然,这个sfc_render就是这个render了,挂载时根据render生成的vnode生成DOM,响应式数据变化后会重新执行render,然后vue的运行时会根据vnode的变化执行DOM操作,掘金已经有很多小伙伴分享这块的内容了。

值得注意的是,上面有这么一个函数

scss 复制代码
const _withScopeId = n => (_pushScopeId("data-v-e17ea971"),n=n(),_popScopeId(),n)

看到data-v-hash大家都很熟悉了,vue的scoped就是通过这个实现的,我们知道vue的模版编译优化,比如我有一段模版:

css 复制代码
  <div>Hello Vite</div>

假如这段模版出现在vue组件中,那么无论响应式数据怎么变化,这段模版肯定都是不变的,因为它是完全静态的,和组件状态无关。所以vue会将这种模版对应的vnode提取出来,就不用每次执行render都重新销毁创建了,所以我们在编译后的代码里看到有许多这种代码: 这种代码就是被提升出来的vnode,但是,又有一个但是,在render函数中生成的vnode是有组件上下文的,也就是组件实例中的scopedId,但提升出来的vnode,只在模块导入时执行一次生成,所以这时候要保证,这时候产生的vnode带有scopeId,所以_withScopeId就起这个作用。

因为对于vue来说模版和scirpt的部分是分别处理的,分别生产对应代码,然后将两端代码拼接起来,所以我们看到的是上边是组件选项,下边是模版渲染函数,

CSS

再往下看可以看到一段导入

arduino 复制代码
import "/src/components/HelloWorld.vue?vue&type=style&index=0&scoped=e17ea971&lang.css"

可以看到导入的是CSS,后面拼接了几个请求参数type=style就不用说了,说明是在请求样式文件,index=0是因为vueSFC可以有多个style块,比如:

xml 复制代码
<style scoped>
.read-the-docs {
  color: #888;
}
</style>

<style scoped>
.read-the-docs {
  font-size: 20px;
}
</style>

这下发出了两个请求 可以看到index的区别,scoped=e17ea971,关于scopeId前面已经讲过了,lang.css则会根据我们在style标签中的lang来决定,比如我这里改成scss: 虽然看起来好像是在请求一个css(或scss等)文件,但实际上css只是vue文件的一部分,vite将vue文件的css处理成一个虚拟模块,至于什么是虚拟模块,可以参考下vite文档,下面我也会稍微提一下这个概念

下面我们看看导入到这个".css文件"到底是个什么,结果出乎意料的是其实devserver并不是返回了css而是一个js文件:

内容如下

javascript 复制代码
import {createHotContext as __vite__createHotContext} from "/@vite/client";
import.meta.hot = __vite__createHotContext("/src/components/HelloWorld.vue?vue&type=style&index=1&scoped=e17ea971&lang.css");
import {updateStyle as __vite__updateStyle, removeStyle as __vite__removeStyle} from "/@vite/client"
const __vite__id = "/Users/username/hook-hmr/src/components/HelloWorld.vue?vue&type=style&index=1&scoped=e17ea971&lang.css"
const __vite__css = "\n.read-the-docs[data-v-e17ea971] {\n  font-size: 20px;\n}\n"
__vite__updateStyle(__vite__id, __vite__css)
import.meta.hot.accept()
export default __vite__css
import.meta.hot.prune(()=>__vite__removeStyle(__vite__id))

最上面是我们刚才说到的热更新,createHotContext,可以看到这段代码还另外从vite的运行时/@vite/client中导入了一个updateStyle,这个其实也是服务于热更新,可以看到这个函数的两个参数是__vite__id和__vite__css,我们编写的css代码已经变成字符串了,在这个updateStyle中,会创建一个style标签然后添加到head标签中,将css字符串的内容作为style标签的innerHtml,这样就实现了导入vue组件对应的样式,同时还会根据上面的__vite__id和css字符串内容保存为key=>map,和上面提到的文件热更新实现类似。热更新时updateStyle函数同样会找到key,更新innerHtml为新的css字符串,实现热更新

模块导出:

可以看到会导入一个_export_sfc函数,这个函数很简单,其实就是把我们前面的sfc_render定义到sfc_main上,熟悉vue的小伙伴都知道,render也是组件选项对象的一部分,如果我们使用jsx语法或者h函数,可以直接在组件选项对象上写render函数,当然除了render函数,还有前面提到的scopeId,已经文件路径,但文件路径只有开发模式才会绑定到组件选项对象上。 最后组件会导出一个默认导出,导出内容就是组件选项对象,当一个vue组件导入另外一个vue组件,本质上都是导入的它的组件选项对象,render渲染时发现是组件就会走组件对应逻辑,进行组件的渲染,这样就构成了组件树。

dev-server部分:

我们前面分析了vue组件的编译后产物,那么下面就往回推,看一看这些产物是怎么生成的,实质上vite本身是没有对vue文件处理的能力,vue单文件组件的支持是 @vitejs/plugin-vue实现的,当然你是vue2.7的话要装这个 @vitejs/plugin-vue2-jsx,下面我们就看看vite和plugin-vue到底擦出了怎样的爱情火花

从请求URL开始:

对于vite来说,开发阶段的源码并不是像webpack一样,会走一个编译流程,打包完后再将打好的包发给浏览器执行。我们创建出来的新项目中都会引入main.js作为入口文件。vite也不例外,但区别是vite的入门文件扫描从html文件开始,为了实现nobundle,vite利用了浏览器的原生es模块加载能力,也就是type=module的script块, 当script声明为module时,就可以引入另外的es模块。而暂时不需要的模块就不需要被引入。这样就实现了按需加载,同时也是为什么我们在vite开发服务器中编写代码时只能使用es模块导入语法的原因。光说可能不好理解,我们看一下新建项目的请求面板:

main.js本身被index.html导入,所以浏览器请求了main.js, 解析执行main.js后发现导入了vue的createApp函数,style.css和App组件,所以我们可以看到浏览器发送了三个请求,又分别请求这三个东西,这就是按需加载

回到vue中来,当我们加载一个vue单文件组件时,发送的请求是这样的

对于vite开发服务器来说,接收到一个请求之后也是根据请求URL决定要返回什么,在这个例子中我们请求helloWorld.vue,dev-server就会解析这个URL,解析URL的行为是由vite插件钩子提供的,vite插件钩子又是对rollup插件接口的拓展,具体可以去看vite文档的插件API这一节。其中url解析是借助resolveId钩子来实现的, 在vite中这个钩子接收了请求的url,将url的地址解析为我们的文件在磁盘中的绝对路径

除了resolveId钩子之外,开发模式下vite中常用的钩子还有load和transform,load钩子可以拿到我们在resolveId钩子中返回的路径,然后决定根据这个路径返回哪些代码,你可能会问,既然在开发服务器知道了文件绝对路径,直接读取出来返回给客户端不就行了吗?这么说倒也没错,但并非所有的URL都是开发服务器上存在的代码文件,比如vite官网就有个虚拟模块的例子,大家可以去看看。transform钩子会接收到我们load钩子返回的代码,我们知道浏览器是有兼容性差异的,通过我们配置的构建目标,借助transform钩子我们可以将源JS代码转换为相应的ES降级语法,当然除了语法降级,也可以进行JSX语法转译,拼接代码等等。transform钩子就像它的名字一样,可以对接收到的源码进行任意操作,只要最后返回我们转换过的代码就可以。

下面就看看helloworld组件的请求到达服务器后具体都做了什么:

第一步我们之前已经说过了,将url转为磁盘绝对路径,也就是在resolveId钩子中将服务器根目录root和请求拿到的路径做一个拼接,如果发现存在那么就返回这个绝对路径,rosolveId钩子中的相关源码在这:

可以看到这个钩子的返回值是磁盘绝对路径。

第二步:vite拿到这个路径之后去尝试调用所有插件中的load钩子,但正常情况下其实我们不需要特殊的加载逻辑,比如上面所述的虚拟模块,亦或着一些静态资源会需要plugin执行load。vite默认状态下会有一个fallback的机制,也就说正常情况下需要load钩子加载的东西都应该已经被加载了,如果load钩子执行完没有东西,那么执行fallback方案就可以了,所以没有被load钩子加载的且在项目根目录的文件都会被读取,比如在这里就是vite会读取出.vue文件的内容字符串

这就是helloWorld.vue文件读取出来的内容

第三步 :现在拿到了.vue文件的内容,但我们的返回值是一个处理好的js模块,所以这就需要我们上面提到的transform钩子对代码进行转译,对vue文件进行转换的transform钩子是plugin-vue插件提供的。我们知道vue仓库中有个包叫compiler-sfc,可以将单文件组件中的代码编译成一个descriptor,这个descriptor分为三个部分,也就是我们代码中常写的template,script(scriptSetup)和style。其中的template会被编译成描述模版结构的AST树,script和style被提取出来成为单独的字符串。就像这样,因为结构复杂我就不全放出来了,感兴趣的小伙伴可以自己从compiler-sfc这个包导入一下parse函数试一下编译vue文件的结果,应该和下图是类似的。

另外还有一个值得讲的就是我们前面提过的data-v-hash值,它也是在这个过程中生成的,可以看到为了保证哈希值的一致性,vite使用的是文件在dev-server下的绝对路径来生成哈希值,因为windows系统的文件路径是反斜杠,为了保证在不同操作系统得到的路径一致,还需要用途中的normalize函数来转成unix的文件路径格式

但这样还没完,compiler还分别暴露了compileScript,compileTemplate函数,用于分别将script代码块和template代码块编译成我们文章开头所讲的样子,也就是组件选项对象和render函数。但是这个过程是比较繁琐的,涉及到很多ast操作,等我学会之后再写一篇文章仔细讲讲。至于style模块,他是不用编译的,style并非是直接拼接在vue文件中,而是会二次请求dev-server,请求vue文件的css时我们可以拿到vue文件对应的哈希值(data-v-hash),vue插件会缓存之前生成的descriptor对象,从descriptor对象上拿到css字符串然后在transform钩子中转成我们前面看到的JS模块的格式就可以了,其实就是拼接一下字符串。感兴趣的小伙伴可以看一下源码

就这样在transform钩子中处理完了这三个模块,有了这三个处理好的模块之后就可以将这三个部分的字符串拼接起来,但除了拼接还不够的,我们开发时需要vue文件有热更新的能力,所以要在拼好的字符串上再拼接上热更新的一部分逻辑,也就是我们上面说过的createHotContext,以及vue插件使用import.meta.hot.accept()实现的vue的热更新逻辑。

现在还有一个问题就是,我们之前将组件选项对象的部分和render函数写在一个文件里了,还需要将render函数定义到组件选项对象上,然后将这个对象作为模块默认导出,这里我把这部分代码放在这方便理解

javascript 复制代码
import _export_sfc from '/@id/plugin-vue:export-helper'
export default /*#__PURE__*/_export_sfc(_sfc_main, [['render',_sfc_render],['__scopeId',"data-v-469af010"],['__file',"/Users/zhangyuxin/hook-hmr/src/components/HelloWorld.vue"]])

先来看第一句的_export_sfc这个函数,可以看到他是一个helper函数,这其实也是一个虚拟模块,会在vue插件的load钩子中被解析,来看一下这是个啥:

ini 复制代码
export default (sfc, props) => {
  const target = sfc.__vccOpts || sfc;
  for (const [key, val] of props) {
    target[key] = val;
  }
  return target;
}

逻辑很简单,其实就是把props放到sfc上,看到这也就可以很自然联想到了,其实就是把render定义到组件选项对象上,其实也就是上面第二句代码的逻辑,到这里我们把这两句代码也拼到前面已经拼好的字符串中,就得到了浏览器得到的返回值

最终transform的返回值就是最终我们在浏览器中看到的结果,一个导出了组件选项对象的ES模块,到这里为止基本的流程就是这样。后面有时间我会写一篇关于vite热更新的文章

相关推荐
世俗ˊ18 分钟前
CSS入门笔记
前端·css·笔记
子非鱼92118 分钟前
【前端】ES6:Set与Map
前端·javascript·es6
6230_23 分钟前
git使用“保姆级”教程1——简介及配置项设置
前端·git·学习·html·web3·学习方法·改行学it
想退休的搬砖人32 分钟前
vue选项式写法项目案例(购物车)
前端·javascript·vue.js
加勒比海涛1 小时前
HTML 揭秘:HTML 编码快速入门
前端·html
啥子花道1 小时前
Vue3.4 中 v-model 双向数据绑定新玩法详解
前端·javascript·vue.js
麒麟而非淇淋1 小时前
AJAX 入门 day3
前端·javascript·ajax
茶茶只知道学习1 小时前
通过鼠标移动来调整两个盒子的宽度(响应式)
前端·javascript·css
清汤饺子1 小时前
实践指南之网页转PDF
前端·javascript·react.js
蒟蒻的贤1 小时前
Web APIs 第二天
开发语言·前端·javascript