本文是 vue3编译原理揭秘 的第 2 篇,和该系列的其他文章一起服用效果更佳。
- vue3的宏到底是什么东西?
- Vue 3 的 setup语法糖到底是什么东西?
- 看不懂来打我,vue3的.vue文件(SFC)编译过程
- 为什么defineProps宏函数不需要从vue中import导入?
- 天天用defineEmits宏函数,竟然不知道编译后是vue2的选项式API?
- 面试官:只知道v-model是:modelValue和@onUpdate语法糖,那你可以走了
- defineModel是否破坏了vue3的单向数据流呢?
- 看不懂来打我,vue3如何将template编译成render函数
- 面试官:来说说vue3是怎么处理内置的v-for、v-model等指令?
- 你不知道的v-model
- vue3早已具备抛弃虚拟DOM的能力了
- vue3编译优化之“静态提升”
- 终于搞懂了!原来 Vue 3 的 generate 是这样生成 render 函数的
- 彻底搞清楚vue3的defineExpose宏函数是如何暴露方法给父组件使用
我们每天写vue3
项目的时候都会使用setup
语法糖,但是你有没有思考过下面几个问题。setup
语法糖经过编译后是什么样子的?为什么在setup
顶层定义的变量可以在template
中可以直接使用?为什么import
一个组件后就可以直接使用,无需使用components
选项来显式注册组件?
要回答上面的问题,我们先来了解一下从一个vue
文件到渲染到浏览器这一过程经历了什么?
我们的vue
代码一般都是写在后缀名为vue的文件上,显然浏览器是不认识vue文件的,浏览器只认识html、css、jss等文件。所以第一步就是通过webpack
或者vite
将一个vue文件编译为一个包含render
函数的js
文件。然后执行render
函数生成虚拟DOM,再调用浏览器的DOM API
根据虚拟DOM生成真实DOM挂载到浏览器上。
欧阳平时写文章参考的多本vue源码电子书、解锁我更多vue原理文章
在javascript
标准中script
标签是不支持setup
属性的,浏览器根本就不认识setup
属性。所以很明显setup
是作用于编译时阶段,也就是从vue文件编译为js文件这一过程。
我们来看一个简单的demo,这个是index.vue
源代码:
1<template>
2 <h1>{{ title }}</h1>
3 <h1>{{ msg }}</h1>
4 <Child />
5</template>
6
7<script lang="ts" setup>
8import { ref } from "vue";
9import Child from "./child.vue";
10
11const msg = ref("Hello World!");
12const title = "title";
13if (msg.value) {
14 const content = "content";
15 console.log(content);
16}
17</script>
这里我们定义了一个名为msg
的ref
响应式变量和非响应式的title
变量,还有import
了child.vue
组件。
这个是child.vue
的源代码
1<template>
2 <div>i am child</div>
3</template>
我们接下来看index.vue
编译后的样子,代码我已经做过了简化:
1import { ref } from "vue";
2import Child from "./Child.vue";
3
4const title = "title";
5
6const __sfc__ = {
7 __name: "index",
8 setup() {
9 const msg = ref("Hello World!");
10 if (msg.value) {
11 const content = "content";
12 console.log(content);
13 }
14 const __returned__ = { title, msg, Child };
15 return __returned__;
16 },
17};
18
19import {
20 toDisplayString as _toDisplayString,
21 createElementVNode as _createElementVNode,
22 createVNode as _createVNode,
23 Fragment as _Fragment,
24 openBlock as _openBlock,
25 createElementBlock as _createElementBlock,
26} from "vue";
27function render(_ctx, _cache, $props, $setup, $data, $options) {
28 return (
29 _openBlock(),
30 _createElementBlock(
31 _Fragment,
32 null,
33 [
34 _createElementVNode("h1", null, _toDisplayString($setup.title)),
35 _createElementVNode(
36 "h1",
37 null,
38 _toDisplayString($setup.msg),
39 1
40 ),
41 _createVNode($setup["Child"]),
42 ],
43 64
44 )
45 );
46}
47__sfc__.render = render;
48export default __sfc__;
我们可以看到index.vue
编译后的代码中已经没有了template
标签和script
标签,取而代之是render
函数和__sfc__
对象。并且使用__sfc__.render = render
将render
函数挂到__sfc__
对象上,然后将__sfc__
对象export default
出去。
看到这里你应该知道了其实一个vue
组件就是一个普通的js对象,import
一个vue
组件,实际就是import
这个js
对象。这个js对象中包含render
方法和setup
方法。
编译后的setup
方法
我们先来看看这个setup
方法,是不是觉得和我们源代码中的setup
语法糖中的代码很相似?没错,这个setup
方法内的代码就是由setup
语法糖中的代码编译后来的。
setup
语法糖原始代码
1<script lang="ts" setup>
2import { ref } from "vue"
3import Child from "./child.vue"
4
5const msg = ref("Hello World!")
6const title = "title"
7if (msg.value) {
8 const content = "content"
9 console.log(content)
10}
11</script>
setup
编译后的代码
1import { ref } from "vue"
2import Child from "./Child.vue"
3
4const title = "title"
5
6const __sfc__ = {
7 __name: "index",
8 setup() {
9 const msg = ref("Hello World!")
10 if (msg.value) {
11 const content = "content"
12 console.log(content)
13 }
14 const __returned__ = { title, msg, Child }
15 return __returned__
16 },
17}
经过分析我们发现title
变量由于不是响应式变量,所以编译后title
变量被提到了js
文件的全局变量上面去了。而msg
变量是响应式变量,所以依然还是在setup
方法中。我们再来看看setup
的返回值,返回值是一个对象,对象中包含title
、msg
、Child
属性,非setup
顶层中定义的content
变量就不在返回值对象中。
看到这里,可以回答我们前面提的第一个问题。
setup
语法糖经过编译后是什么样子的?
setup
语法糖编译后会变成一个setup
方法,编译后setup
方法中的代码和script
标签中的源代码很相似。方法会返回一个对象,对象由setup
中定义的顶层变量和import
导入的内容组成。
由template
编译后的render
函数
我们先来看看原本template
中的代码:
1<template>
2 <h1>{{ title }}</h1>
3 <h1>{{ msg }}</h1>
4 <Child />
5</template>
我们再来看看由template
编译成的render
函数:
1import {
2 toDisplayString as _toDisplayString,
3 createElementVNode as _createElementVNode,
4 createVNode as _createVNode,
5 Fragment as _Fragment,
6 openBlock as _openBlock,
7 createElementBlock as _createElementBlock,
8} from "vue";
9function render(_ctx, _cache, $props, $setup, $data, $options) {
10 return (
11 _openBlock(),
12 _createElementBlock(
13 _Fragment,
14 null,
15 [
16 _createElementVNode("h1", null, _toDisplayString($setup.title)),
17 _createElementVNode(
18 "h1",
19 null,
20 _toDisplayString($setup.msg),
21 1
22 ),
23 _createVNode($setup["Child"]),
24 ],
25 64
26 )
27 );
28}
我们这次主要看在render
函数中如何访问setup
中定义的顶层变量title
、msg
,createElementBlock
和createElementVNode
等创建虚拟DOM的函数不在这篇文章的讨论范围内。你只需要知道createElementVNode("h1", null, _toDisplayString($setup.title))
为创建一个h1
标签的虚拟DOM就行了。
在render
函数中我们发现读取title
变量的值是通过$setup.title
读取到的,读取msg
变量的值是通过$setup.msg
读取到的。这个$setup
对象就是调用render
函数时传入的第四个变量,我想你应该猜出来了,这个$setup
对象和前面的setup
方法返回的对象有关系。
那么问题来了,在执行render
函数的时候是如何将setup
方法的返回值作为第四个变量传递给render
函数的呢?我在下一节会一步一步的带你通过debug
源码的方式去搞清楚这个问题,我们带着问题去debug
源码其实非常简单。
有的小伙伴看到这里需要看源码就觉得头大了,别着急,其实很简单,我会一步一步的带着你去debug源码。
首先我们将Enable JavaScript source maps
给取消勾选了,不然在debug源码的时候断点就会走到vue
文件中,而不是走到编译会的js文件中。
然后我们需要在设置里面的Ignore List看看node_modules
文件夹是否被忽略。新版谷歌浏览器中会默认排除掉node_modules
文件夹,所以我们需要将这个取消勾选。如果忽略了node_modules
文件夹,那么debug
的时候断点就不会走到node_modules
中vue
的源码中去了。
接下来我们需要在浏览器中找到vue文件编译后的js代码,我们只需要在network
面板中找到这个vue
文件的http
请求,然后在Response
下右键选择Open in Sources panel
,就会自动在sources
面板自动打开对应编译后的js文件代码。
找到编译后的js文件,我们想debug
看看是如何调用render
函数的,所以我们给render函数加一个断点。然后刷新页面,发现代码已经走到了断点的地方。我们再来看看右边的Call Stack调用栈,发现render
函数是由一个vue
源码中的renderComponentRoot
函数调用的。
点击Call Stack中的renderComponentRoot
函数就可以跳转到renderComponentRoot
函数的源码,我们发现renderComponentRoot
函数中调用render
函数的代码主要是下面这样的:
1function renderComponentRoot(instance) {
2 const {
3 props,
4 data,
5 setupState,
6
7 } = instance;
8
9 render2.call(
10 thisProxy,
11 proxyToUse,
12 renderCache,
13 props,
14 setupState,
15 data,
16 ctx
17 )
18}
这里我们可以看到前面的$setup
实际就是由setupState
赋值的,而setupState
是当前vue实例上面的一个属性。那么setupState
属性是如何被赋值到vue
实例上面的呢?
我们需要给setup
函数加一个断点,然后刷新页面进入断点。通过分析Call Stack调用栈,我们发现setup
函数是由vue
中的一个setupStatefulComponent
函数调用执行的。
点击Call Stack调用栈中的setupStatefulComponent
,进入到setupStatefulComponent
的源码。我们看到setupStatefulComponent
中的代码主要是这样的:
1function setupStatefulComponent(instance) {
2 const { setup } = Component;
3
4 const setupResult = callWithErrorHandling(
5 setup,
6 instance
7 );
8 handleSetupResult(instance, setupResult);
9}
setup
函数是Component
上面的一个属性,我们将鼠标放到Component
上面,看看这个Component
是什么东西?
看到这个Component
对象中既有render
方法也有setup
方法是不是感觉很熟悉,没错这个Component
对象实际就是我们的vue
文件编译后的js对象。
1const __sfc__ = {
2 __name: "index",
3 setup() {
4 const msg = ref("Hello World!")
5 if (msg.value) {
6 const content = "content"
7 console.log(content)
8 }
9 const __returned__ = { title, msg, Child }
10 return __returned__
11 },
12}
13
14__sfc__.render = render
从Component对象中拿到setup
函数,然后执行setup
函数得到setupResult
对象。然后再调用handleSetupResult(instance, setupResult);
我们再来看看handleSetupResult
函数是什么样的,下面是我简化后的代码:
1function handleSetupResult(instance, setupResult) {
2 if (isFunction(setupResult)) {
3
4 } else if (isObject(setupResult)) {
5 instance.setupState = proxyRefs(setupResult);
6 }
7}
我们的setup
的返回值是一个对象,所以这里会执行instance.setupState = proxyRefs(setupResult)
,将setup执行后的返回值经过new Proxy
处理后赋值到vue实例的setupState属性上,而new Proxy
中的get
就是用于处理在template
中使用ref
不需要使用.value
的写法。
看到这里我们整个流程已经可以串起来了,首先会执行由setup
语法糖编译后的setup
函数。然后将setup
函数中由顶层变量和import
导入组成的返回值对象经过Proxy
处理后赋值给vue
实例的setupState
属性,然后执行render
函数的时候从vue
实例中取出setupState
属性也就是setup
的返回值。这样在render
函数也就是template
模版就可以访问到setup
中的顶层变量和import
导入。
现在我们可以回答前面提的另外两个问题了:
为什么在setup
顶层定义的变量可以在template
中可以直接使用?
因为在setup
语法糖顶层定义的变量经过编译后会被加入到setup
函数返回值对象__returned__
中,而非setup
顶层定义的变量不会加入到__returned__
对象中。setup
函数返回值经过Proxy
处理后会被塞到vue
实例的setupState
属性上,执行render
函数的时候会将vue
实例上的setupState
属性传递给render
函数,所以在render
函数中就可以访问到setup
顶层定义的变量和import
导入。而render
函数实际就是由template
编译得来的,所以说在template
中可以访问到setup
顶层定义的变量和import
导入。。
为什么import
一个组件后就可以直接使用,无需使用components
选项来显式注册组件?
因为在setup
语法糖中import
导入的组件对象经过编译后同样也会被加入到setup
函数返回值对象__returned__
中,同理在template
中也可以访问到setup
的返回值对象,也就可以直接使用这个导入的组件了。
setup
语法糖经过编译后就变成了setup
函数,而setup
函数的返回值是一个对象,这个对象就是由在setup
顶层定义的变量和import
导入组成的。vue
在初始化的时候会执行setup
函数,然后将setup
函数返回值经过Proxy
处理后塞到vue
实例的setupState
属性上。执行render
函数的时候会将vue
实例上的setupState
属性(也就是setup
函数的返回值)传递给render
函数,所以在render
函数中就可以访问到setup
顶层定义的变量和import
导入。而render
函数实际就是由template
编译得来的,所以说在template
中就可以访问到setup
顶层定义的变量和import
导入。