前言-初遇bug
这两天做低代码平台开发的时候遇到奇怪的bug,排查花费了不少时间,还逼我去读了VCA的源码,特地在这里记录一下。
组件库内引入了一个新的组件,该组件依赖了@vue/composition-api
这个库(以下称VCA),因此组件库打包的时候将VCA也打包了。而该组件库的umd文件在低代码平台进行了加载,就导致低代码平台内的渲染器调用getCurrentInstance
这个方法时,获取到的实例是null
。
遇到这种事,肯定要研究一下,为什么获取不到实例了。于是就去研究了一下VCA的源码。下列源码都以1.7.0版本为例子进行介绍。
一、注册前
常规项目中,我们都是通过下面这种方式将VCA注册到Vue上:
javascript
import Vue from 'vue';
import VCA from '@vue/composition-api';
...
Vue.use(VCA);
...
而对于CDN方式引入VCA插件的情况,该如何注册呢?其实VCA内部做了兼容处理:
在src/index.ts
文件内,有专门针对CDN方式使用VCA的入口。VCA会自动取寻找Vue,并将VCA插件进行注册。
到这里读者可能会有疑问,即使我的项目不是通过CDN方式加载的,但如果window上挂载了Vue,还是会触发这里的注册。我在项目内部已经Vue.use()了一次,加上这里的一次注册,那我的项目还是会注册两次VCA么?这里我们就需要关注注册的过程都发生了什么,如何确保一个VCA只会注册一次。
在这次的问题项目中,我们通过CDN的方式加载了组件库,组件库内打包了VCA,而正好我们也将Vue挂载到了window上,因此这个VCA也被注册了。
1.1 注册过程发生了什么?
即使知道VCA被重复注册了,我们也不清楚为什么插件重复注册会导致获取不到实例。再加上前面提出的问题,如何确保一个VCA不会被注册两次呢?我们需要观察注册过程中都发生了什么:
这是在src/install.ts
文件中的注册函数。我们可以观察到VCA是通过install的方式进行注册的。而注册的主要流程是四个:
- 判断该VCA是否已经被注册过,注册过直接返回,啥也不干。
- 对setup进行合并。
- 将当前Vue设置为已注册。
- 将VCA注册到Vue上。
这就是核心的四个流程。第一步和第二步先不看,我们先看第三步setVueConstructor
做了什么:
setVueConstructor
就一个核心功能,将vueConstructor赋值为将要注册的Vue; 往Vue上设置一个'composition_api_installed'属性,将值设置为true。
这样我们再回过头来看第一步是如何判断Vue是否已注册过:
VCA会判断两个东西:vueConstructor,且Vue上是否有__composition_api_installed__
这个属性。这个vueConstructor实际上就是VCA内部的一个全局变量,在注册函数的第三步,将其赋值为了Vue,且Vue也设置了__composition_api_installed__属性。
1.2 VCA对于重复注册的处理
阅读到这里,对于之前提出的问题,我们能够给出答案:对于同一个VCA,能够避免重复注册,但如果是两个VCA,就都会注册到Vue上。
对于同一个VCA,是能够避免重复注册的。VCA在未注册时,vueConstructor为null,isVueRegistered返回null,会执行后续的注册流程,将vueConstructor赋值为Vue。无论是后续重复Vue.use(VCA),还是在window上获取Vue进行注册,都能识别到vueConstructor已经有值,且Vue上已有__composition_api_installed__属性,将会被判定为已注册过,不会重复注册。
而如果加载了第二个VCA(例如此处业务场景,CDN方式又引入了一个VCA),那么对于第二个VCA来说,其vueConstructor变量初始还是为null,这就会导致其判定结果为未注册,执行后续注册流程,将第二个VCA注册到Vue上。
从代码上看,这似乎是开发团队有意为之。对于同一个VCA,当然要避免重复注册。而如果你项目中有两个VCA,那么这可能是有意为之,系统将会允许你注册两个不同的VCA。
那么两个VCA都注册了会有什么后果呢?
二、注册VCA
注册VCA的过程,核心就是就是之前install函数中的最后一步,mixin(Vue)
。我们来详细看一下这里做了什么:
我们可以观察到,注册VCA的过程,实际上就是在Vue上进行mixin,在几个生命周期做了一些工作。我们最常用的setup,实际上就是在beforeCreate这里初始化的。
2.1 初始化API
我们再看看在beforeCreate里执行了什么:
首先是对于render,会将vm包裹一下,包裹成vue3结构的instance。
这里对render进行了重写,在原始render执行前,通过activateCurrentInstance,设置currentInstance,等原始render执行完后,再将currentInstance设置为回去。这样就保证嵌套结构中,能够获取到正确的instance。
2.2 初始化setup
上面的内容都旁枝末节,下面我们来重点看看initSetup做了什么:
我们可以观察到,在这里,我们会执行setup,在执行之前,我们会做一些预处理,包括创建setup的上下文,以及将vm包裹为vue3结构的实例。这样在setup内部,我们能够正确访问到currentInstance。
执行完setup后,根据setup返回值的类型进行不同的处理,返回如果是函数,那么就认为是render函数,替换vm的render函数;如果setup返回了一个对象,那么就对这个对象进行处理。
在对setup返回的对象进行处理的过程中,我们会将对象的值进行处理。如果一个值没有被响应式,且是函数,那么这个函数会被绑定到vm上;如果这个值没被响应式,且不是object类型(如基本类型),那么就会被ref处理成响应式。如果一个非响应式的object其内部有响应式的数组,那么会有特殊的处理。看官方的注释,不推荐这么搞。
这里有个奇怪的点,如果这个值是一个reactive的数组,会用ref将其再包裹后,才挂载到vm上。目前暂不清楚为什么要这么做。
2.3 关于Vue.mixin
后续的其他内容咱们这里就先不管了。代码看到这里,似乎还是看不出来为什么instance会变成null。这其实和mixin的机制有关系。
刚刚我们看的functionApiInit
,是在组件的beforeCreate这个生命周期内执行的,是通过mixin注册到全局的。
我们来看Vue.mixin这个API的定义:
全局注册一个混入,影响注册之后所有创建的每个 Vue 实例。插件作者可以使用混入,向组件注入自定义的行为。不推荐在应用代码中使用。
而关于mixin,官方的解释在这里:混入 --- Vue.js
这里面有一个非常重要的内容,即
同名钩子函数将合并为一个数组,因此都将被调用。另外,混入对象的钩子将在组件自身钩子之前调用。
也就是说,如果我们有多个插件注册到了Vue上,且这些插件声明了某个生命周期的钩子函数(例如beforeCreate),那么组件的同名钩子函数会被合并为数组,将这些生命周期函数取出来依次执行。
在这里,也就是我们的两个VCA都注册到了Vue上,则两次mixin的functionApiInit会依次执行。
2.4 问题复现
我们通过一个简单的测试能够证明这一点:
1、首先我们的项目本身应该注册了一个VCA,且Vue应该挂载到window上,这样我们通过CDN方式引入的VCA也能正确注册。
javascript
import Vue from 'vue';
import App from './App.vue';
import VCA from '@vue/composition-api';
Vue.config.productionTip = false;
Vue.use(VCA);
window.Vue = Vue;
new Vue({
render: (h) => h(App),
}).$mount('#app');
2、在父组件中我们以CDN的形式加载一个VCA,此时我们的子组件还未渲染。
xml
<template>
<div id="app">
<img alt="Vue logo" src="./assets/logo.png" />
<HelloWorld v-if="show" msg="Hello Vue 2 + Vite" />
<button @click="clickBtn">切换子组件是否展示</button>
</div>
</template>
<script>
import HelloWorld from "./components/HelloWorld.vue";
import {
getCurrentInstance,
ref,
onMounted,
} from "@vue/composition-api";
export default {
components: {
HelloWorld,
},
setup() {
const instance = getCurrentInstance();
console.log("父组件setup执行,获取instance", instance);
const show = ref(false);
const clickBtn = () => {
show.value = !show.value;
};
onMounted(() => {
const script = document.createElement("script");
script.src = "https://unpkg.com/@vue/composition-api@1.7.0/dist/vue-composition-api.prod.js";
document.head.appendChild(script);
console.log("父组件以CDN的形式再次加载一个VCA");
});
return {
show,
clickBtn,
};
}
};
</script>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
margin-top: 60px;
}
</style>
在父组件创建时,额外的VCA还未注册,此时Vue就mixin了一次VCA,setup应该只执行一次。
看看控制台打印,符合预期。
3、等CDN加载完成,且第二个VCA注册完成后(稍等几秒,确认VCA注册完成),我们点击按钮,触发子组件的创建。此时Vue已经mixin了两次,子组件setup函数应该执行两次。
xml
<template>
<div>
<h1>{{ msg }}</h1>
我是子组件
</div>
</template>
<script>
import {getCurrentInstance } from '@vue/composition-api';
export default {
props: {
msg: String,
},
setup() {
const instance = getCurrentInstance();
console.log('子组件创建,获取instance',instance)
},
};
</script>
<style scoped>
a {
color: #42b983;
}
</style>
观察控制台打印,符合预期。那为什么setup执行,其中一次取到的isntance会是null呢?
2.5 执行流程
我们通过阅读源码,能够发现,所谓的getCurrentInstance
方法,实际上就是使用一个变量currentInstance保存当前组件的实例。而在setup执行前,通过activateCurrentInstance
方法,更新currentInstance,等setup执行完后,就将currentInstance恢复为之前的值。
也就是当我两次执行的时候,首先是第二个VCA的initSetup,然后是第一个VCA的initSetup。而getCurrentInstance
方法始终是获取的是第一个VCA的当前实例。当执行第二个VCA的initSetup时,第一个VCA的currentInstance没有被赋值为当前实例,因此返回了null。
用一张图表示:
这里我们又要抛出疑问了。按官方文档的描述,钩子函数的执行顺序明明是按注册顺序来的,第一个VCA的functionApiInit先于第二个VCA执行,这里没问题。但是为什么在initState阶段,第二个VCA的initSetup会先于第一个VCA执行呢?
这里我们要回过头去看源码。实际上在functionApiInit中,initSetup并不是立即执行的,而是修改了data函数。
延迟到data函数执行时,再执行的initSetup。
也就是在这个子组件中,data函数一共被修改了两次。
第一次是第一个VCA的functionApiInit执行,data被改写,第一个initSetup会先被调用,然后才执行原始的data函数。
第二次是第二个VCA的fucntionApiInit执行,data再次被改写,第二个initSetup会被调用,然后才执行被前一个data函数,即之前被改写的data函数。
data经过这样的层层改写,最后的调用顺序就变成了先改写data的后执行,后改写的先执行。
data何时执行
那么我们简单查阅一下vue2的源码,能够注意到,data函数的最终调用是在beforeCreate钩子之后,created之前。具体是在initState这个函数内进行的调用。
三、其他内容
3.1 defineComponent做了什么?
在src/component/defineComponent
中,除了几个重载,defineComponent其实啥也没做。
在这里,defineComponent只是通过重载,能让你的TS进行正确的类型推导,除此之外,别无他用。
3.2 getCurrentInstance实现原理
实际上VCA只是使用了一个变量来保存当前组件的实例。
每当setup执行前会将currentInstance赋值为当前组件实例,等钩子函数执行完成后,再将currentInstance重新赋值为之前的组件实例。当然不只是setup,例如VCA提供的onBeforeMount,onMounted等钩子函数,在执行前后也会更改当前组件实例。
如果此时没有其他组件钩子正在执行(例如组件套组件,生命周期钩子函数的执行就会有交替),那么前一个的组件实例就是null了。
可以简单认为这是类似于栈的设计。新的钩子函数执行就入栈,执行完成就出栈,currentInstance始终返回栈顶元素。
3.3 Reactive做了什么?
可以简单认为这就是Vue.observable的语法糖。
scss
export function observe<T>(obj: T): T {
const Vue = getRegisteredVueOrDefault()
let observed: T
if (Vue.observable) {
observed = Vue.observable(obj)
} else {
const vm = defineComponentInstance(Vue, {
data: {
$$state: obj,
},
})
observed = vm._data.$$state
}
// in SSR, there is no __ob__. Mock for reactivity check
if (!hasOwn(observed, '__ob__')) {
mockReactivityDeep(observed)
}
return observed
}
/**
* Make obj reactivity
*/
export function reactive<T extends object>(obj: T): UnwrapRef<T> {
if (!isObject(obj)) {
if (__DEV__) {
warn('"reactive()" must be called on an object.')
}
return obj
}
if (
!(isPlainObject(obj) || isArray(obj)) ||
isRaw(obj) ||
!Object.isExtensible(obj)
) {
return obj as any
}
const observed = observe(obj)
setupAccessControl(observed)
return observed as UnwrapRef<T>
}
即VCA是向前兼容的,并没有使用Proxy,但这也就导致了和Vue3的差异。VCA的Reactive传入的对象会被变更,和返回的对象是同一个对象;而Vue3中的Reactive返回的是一个新的代理对象。这一点在官方文档上也有提及。